From 836c79cfc4161ad437022c421cce3b4811950d5f Mon Sep 17 00:00:00 2001 From: supreme2580 Date: Fri, 28 Aug 2026 18:43:40 +0100 Subject: [PATCH] feat(explorer): honest error and empty states (#447) - Contract page: instant SSR shell; table + all query-dependent states (loading skeleton, no events, not indexed, invalid contract, not found, indexer unavailable) rendered client-side - Live feed: SSE proxy keeps the API key server-side, auto-resume via Last-Event-ID, visible connection pill, gap/notice handling - Event detail: error states instead of raw err.message - Homepage ticker: loading skeleton, honest empty state, unavailable panel with retry - Local strkey + CRC16 contract validation; on-chain Soroban RPC probe to distinguish not-indexed from no-events --- explorer/.env.example | 6 + explorer/README.md | 19 +- explorer/src/components/StatePanel.astro | 68 ++ explorer/src/lib/api.ts | 82 +- explorer/src/lib/contract-page.ts | 700 ++++++++++++++++++ explorer/src/lib/contracts.ts | 58 ++ explorer/src/lib/soroban.ts | 115 +++ explorer/src/lib/state-panel.ts | 14 + explorer/src/lib/ticker.ts | 153 ++++ explorer/src/lib/types.ts | 49 ++ explorer/src/pages/api/events.json.ts | 135 +++- explorer/src/pages/api/events/stream.ts | 62 ++ explorer/src/pages/api/recent-events.json.ts | 71 +- .../pages/contract/[address]/event/[id].astro | 98 ++- .../src/pages/contract/[address]/index.astro | 251 +------ explorer/src/pages/index.astro | 103 +-- 16 files changed, 1627 insertions(+), 357 deletions(-) create mode 100644 explorer/src/components/StatePanel.astro create mode 100644 explorer/src/lib/contract-page.ts create mode 100644 explorer/src/lib/contracts.ts create mode 100644 explorer/src/lib/soroban.ts create mode 100644 explorer/src/lib/state-panel.ts create mode 100644 explorer/src/lib/ticker.ts create mode 100644 explorer/src/pages/api/events/stream.ts diff --git a/explorer/.env.example b/explorer/.env.example index 50000992..848b5f5e 100644 --- a/explorer/.env.example +++ b/explorer/.env.example @@ -4,3 +4,9 @@ TRIDENT_MAINNET_API_URL=https://api.mainnet.trident.dev # Internal API key for the explorer (free tier — created at deploy time, never exposed to clients) EXPLORER_API_KEY=your_internal_explorer_api_key + +# Soroban RPC endpoints used to probe a contract on-chain (best-effort: +# distinguishes "not indexed yet" from "no events yet"). Defaults shown; +# override if you operate your own RPC. +TRIDENT_TESTNET_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +TRIDENT_MAINNET_SOROBAN_RPC_URL=https://mainnet.sorobanrpc.com \ No newline at end of file diff --git a/explorer/README.md b/explorer/README.md index 6254fbc0..8fbccabe 100644 --- a/explorer/README.md +++ b/explorer/README.md @@ -13,8 +13,11 @@ Public event explorer for Soroban contracts on Stellar. Read-only, no API key re | Path | Description | |------|-------------| | `/` | Landing page — search + live recent events ticker | -| `/contract/:address` | All events for a contract, paginated, server-rendered | +| `/contract/:address` | All events for a contract, paginated. Table and every query-dependent state render client-side over an API route so slow queries show a deliberate skeleton | | `/contract/:address/event/:id` | Single event detail, shareable, og:tags | +| `/api/events.json` | Events API with classified result states (see below) | +| `/api/events/stream` | Server-side SSE proxy for the live feed (keeps `EXPLORER_API_KEY` and the `Last-Event-ID` handshake private) | +| `/api/recent-events.json` | Recent events feed for the homepage ticker | ## Setup @@ -35,9 +38,23 @@ npm run lint # type-check with astro check | `TRIDENT_TESTNET_API_URL` | Yes | Base URL for the testnet Trident REST API | | `TRIDENT_MAINNET_API_URL` | Yes | Base URL for the mainnet Trident REST API | | `EXPLORER_API_KEY` | Yes | Internal API key (free tier, created at deploy time) | +| `TRIDENT_TESTNET_SOROBAN_RPC_URL` | No | Soroban RPC used to probe contracts on-chain (testnet default: `https://soroban-testnet.stellar.org`) | +| `TRIDENT_MAINNET_SOROBAN_RPC_URL` | No | Soroban RPC used to probe contracts on-chain (mainnet default: `https://mainnet.sorobanrpc.com`) | The `EXPLORER_API_KEY` is used server-side only and is never sent to the browser. +## Result states + +The explorer distinguishes failures deliberately instead of showing blank pages or raw errors: + +- **Loading**: queries that take longer than ~300ms show a content skeleton, never an empty wait. +- **No events yet**: the contract is quiet (and being watched live), so nothing is missed. +- **Not indexed yet**: the contract is emitting on-chain events but Trident hasn't indexed them — shown only after a best-effort Soroban RPC probe confirms the contract is live. +- **Invalid contract**: the searched address fails the Stellar strkey format + checksum check, answered locally in milliseconds. +- **Not found**: the event or contract isn't in the index (e.g. rotated out of retention). +- **Indexer unavailable**: any upstream failure maps to a human-readable reason (`network`, `rate_limited`, `unauthorized`, `timeout`, `down`) with a retry path. +- **Live feed status**: the contract page shows a persistent connection pill (connecting / live / reconnecting / off) and auto-resumes the SSE stream via `Last-Event-ID`, so no events are skipped during a drop. + ## Rate limiting - The explorer uses an internal `EXPLORER_API_KEY` at the free tier (60 req/min). diff --git a/explorer/src/components/StatePanel.astro b/explorer/src/components/StatePanel.astro new file mode 100644 index 00000000..10b34a2f --- /dev/null +++ b/explorer/src/components/StatePanel.astro @@ -0,0 +1,68 @@ +--- +import type { PanelAction, PanelState } from '../lib/state-panel'; + +interface Props { + state: PanelState; + title: string; + message: string; + actions?: PanelAction[]; +} + +const { state, title, message, actions = [] } = Astro.props; + +const icons: Record = { + no_events: + '', + not_indexed: + '', + invalid_contract: + '', + api_unreachable: + '', + not_found: + '', + info: + '', +}; +--- + +
+
+

{title}

+

{message}

+ { + actions.length > 0 && ( +
+ { + actions.map((action) => + action.variant === 'primary' ? ( + + {action.label} + + ) : ( + + {action.label} + + ), + ) + } +
+ ) + } +
\ No newline at end of file diff --git a/explorer/src/lib/api.ts b/explorer/src/lib/api.ts index fae8979c..e0922986 100644 --- a/explorer/src/lib/api.ts +++ b/explorer/src/lib/api.ts @@ -14,6 +14,52 @@ function authHeaders(): HeadersInit { return h; } +/** + * Typed error for a non-OK Trident API response. `code` is the machine + * readable code from the standard {"error":{code,message}} envelope so callers + * can surface a deliberate state instead of a raw error string. + */ +export class ApiError extends Error { + readonly status: number; + readonly code: string; + readonly requestId?: string; + + constructor(status: number, code: string, message: string, requestId?: string) { + super(message || `Request failed (HTTP ${status})`); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.requestId = requestId; + } +} + +/** Fetch a JSON body and throw an {@link ApiError} on any non-OK response. */ +async function fetchJson(url: string, init?: RequestInit): Promise { + let res: Response; + try { + res = await fetch(url, init); + } catch { + throw new ApiError(0, 'NETWORK', 'Could not reach the indexer'); + } + if (!res.ok) { + let code = ''; + let message = ''; + let requestId: string | undefined; + try { + const body = (await res.json()) as { + error?: { code?: string; message?: string; request_id?: string }; + }; + code = body?.error?.code ?? ''; + message = body?.error?.message ?? ''; + requestId = body?.error?.request_id; + } catch { + // Non-JSON error body — fall through with generic values. + } + throw new ApiError(res.status, code || `HTTP_${res.status}`, message, requestId); + } + return (await res.json()) as T; +} + export interface QueryEventsParams { contractId?: string; topic0?: string; @@ -34,16 +80,36 @@ export async function listEvents(params: QueryEventsParams = {}): Promise(url.toString(), { headers: authHeaders() }); } export async function getEvent(id: string, network: Network = 'testnet'): Promise { - const res = await fetch(`${baseUrl(network)}/v1/events/${encodeURIComponent(id)}`, { - headers: authHeaders(), - }); - if (!res.ok) throw new Error(`API ${res.status}`); - const body = (await res.json()) as { event: SorobanEvent }; + const body = await fetchJson<{ event: SorobanEvent }>( + `${baseUrl(network)}/v1/events/${encodeURIComponent(id)}`, + { headers: authHeaders() }, + ); return body.event; } + +/** + * Build the Trident SSE stream URL for a contract. This is fetched by the + * explorer's own /api/events/stream proxy (never directly from the browser), + * so the API key and the Last-Event-ID handshake stay server-side. + */ +export function streamUrl(network: Network, contractId: string, topic0 = ''): string { + const url = new URL(`${baseUrl(network)}/v1/events/stream`); + url.searchParams.set('contractId', contractId); + if (topic0) url.searchParams.set('topic0', topic0); + return url.toString(); +} + +/** Base headers for streaming requests (server-side only). */ +export function streamHeaders(lastEventId?: string): HeadersInit { + const h: Record = { + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + }; + if (API_KEY) h['X-API-Key'] = API_KEY; + if (lastEventId) h['Last-Event-ID'] = lastEventId; + return h; +} \ No newline at end of file diff --git a/explorer/src/lib/contract-page.ts b/explorer/src/lib/contract-page.ts new file mode 100644 index 00000000..9e6ecc58 --- /dev/null +++ b/explorer/src/lib/contract-page.ts @@ -0,0 +1,700 @@ +import type { ExplorerEventsResponse, Network, SorobanEvent, StreamedEvent } from './types'; +import { relativeTime, truncate, stellarExpertBase, tryFormatJson } from './format'; + +/* ------------------------------------------------------------------ * + * Page state (derived from the URL — works on first load and after a + * full-filter navigation) + * ------------------------------------------------------------------ */ + +const params = new URLSearchParams(window.location.search); +const network: Network = params.get('network') === 'mainnet' ? 'mainnet' : 'testnet'; +const pathParts = window.location.pathname.split('/'); +const contractId = decodeURIComponent(pathParts[2] ?? ''); +const topic0 = params.get('topic0') ?? ''; +const ledgerFrom = params.get('ledgerFrom') ?? ''; +const ledgerTo = params.get('ledgerTo') ?? ''; +const rangeFiltered = Boolean(ledgerFrom || ledgerTo); + +const expertBase = stellarExpertBase(network); + +/* ------------------------------------------------------------------ * + * Helpers + * ------------------------------------------------------------------ */ + +function esc(v: unknown): string { + return String(v ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function eventsApiUrl(): string { + const p = new URLSearchParams({ network, contractId }); + if (topic0) p.set('topic0', topic0); + if (ledgerFrom) p.set('ledgerFrom', ledgerFrom); + if (ledgerTo) p.set('ledgerTo', ledgerTo); + return `/api/events.json?${p.toString()}`; +} + +function eventDetailHref(ev: { contract_id: string; id: string }): string { + if (!ev.id) return ''; + return `/contract/${encodeURIComponent(ev.contract_id)}/event/${encodeURIComponent(ev.id)}?network=${network}`; +} + +function normalizeStreamedEvent(raw: StreamedEvent): SorobanEvent { + let topics: string[] = []; + try { + const parsed = JSON.parse(raw.topics); + if (Array.isArray(parsed)) topics = parsed.map((t) => String(t)); + } catch { + topics = []; + } + return { + id: raw.event_id ?? '', + contract_id: raw.contract_id, + ledger_sequence: Number(raw.ledger_sequence) || 0, + ledger_timestamp: raw.ledger_timestamp, + transaction_hash: raw.transaction_hash ?? '', + event_index: Number(raw.event_index) || 0, + event_type: raw.event_type ?? 'contract', + topics, + data: raw.data ?? '', + created_at: raw.ledger_timestamp, + }; +} + +function eventRowHtml(e: SorobanEvent): string { + const href = eventDetailHref(e); + const topic0Badge = e.topics[0] ?? e.event_type ?? 'event'; + const topic1 = e.topics[1]; + return ` + + ${esc(relativeTime(e.ledger_timestamp))} + ${esc(String(e.ledger_sequence))} + ${esc(topic0Badge)} + ${topic1 ? esc(truncate(topic1, 12, 8)) : '—'} + + ${e.transaction_hash + ? `${esc(truncate(e.transaction_hash, 8, 6))}` + : ''} + + ${e.data ? esc(tryFormatJson(e.data).slice(0, 60)) : '—'} + `.trim(); +} + +function skeletonHtml(): string { + const rows = Array.from( + { length: 8 }, + () => ` +
+
+
+
+ +
`, + ).join(''); + return ` + +

Loading events…

`; +} + +/* ------------------------------------------------------------------ * + * State panels (honest + actionable, no raw error strings) + * ------------------------------------------------------------------ */ + +type PanelAction = + | { label: string; action: 'retry' | 'reconnect' | 'clear-filters'; kind: 'primary' | 'ghost' } + | { label: string; href: string; kind: 'primary' | 'ghost'; external?: boolean }; + +const icon = (glyph: string) => ` + `; + +const ICONS: Record = { + no_events: + '', + not_indexed: + '', + invalid_contract: + '', + api_unreachable: + '', + not_found: + '', +}; + +function panelHtml(opts: { + icon: string; + title: string; + message: string; + actions?: PanelAction[]; +}): string { + const buttons = (opts.actions ?? []) + .map((a) => { + const cls = + a.kind === 'primary' + ? 'px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors' + : 'px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white text-sm font-medium transition-colors'; + if ('action' in a) { + return ``; + } + const ext = a.external ? 'target="_blank" rel="noopener noreferrer"' : ''; + return `${esc(a.label)}`; + }) + .join(''); + return ` +
+
${icon(opts.icon)}
+

${esc(opts.title)}

+

${esc(opts.message)}

+ ${buttons ? `
${buttons}
` : ''} +
`; +} + +function renderNoEvents(filtered: boolean): void { + root().innerHTML = panelHtml( + filtered + ? { + icon: ICONS.no_events, + title: 'No events match your filters', + message: + 'No events for this contract match the current topic or ledger filters. Clear the filters to see everything Trident has indexed for this contract.', + actions: [ + { label: 'Clear filters', kind: 'primary', action: 'clear-filters' }, + { label: 'Back to search', kind: 'ghost', href: '/?network=' + network }, + ], + } + : { + icon: ICONS.no_events, + title: 'No events yet', + message: + 'Trident has not recorded any events for this contract yet. If this contract was just deployed, its events will appear here as soon as it emits one — you are watching this contract live, so nothing will be missed.', + actions: [ + { label: 'View on Stellar Expert', kind: 'ghost', href: `${expertBase}/contract/${encodeURIComponent(contractId)}`, external: true }, + { label: 'Back to search', kind: 'ghost', href: '/?network=' + network }, + ], + }, + ); +} + +function renderNotIndexed(): void { + root().innerHTML = panelHtml({ + icon: ICONS.not_indexed, + title: 'Contract not indexed yet', + message: + `This contract is emitting events on the Stellar ${network} network, but Trident has not indexed any of them yet. ` + + 'Indexing may be catching up — retry in a moment, or check back shortly. In the meantime you can inspect the contract directly on Stellar Expert.', + actions: [ + { label: 'Retry', kind: 'primary', action: 'retry' }, + { label: 'View on Stellar Expert', kind: 'ghost', href: `${expertBase}/contract/${encodeURIComponent(contractId)}`, external: true }, + { label: 'Back to search', kind: 'ghost', href: '/?network=' + network }, + ], + }); +} + +function renderInvalidContract(): void { + root().innerHTML = panelHtml({ + icon: ICONS.invalid_contract, + title: "That doesn't look like a Stellar contract address", + message: + 'A Soroban contract address is a 56-character string starting with the letter C (for example C…). The address you searched for does not have the right format, so it cannot be a contract. Double-check for typos, copy the full address, and search again.', + actions: [ + { label: 'Back to search', kind: 'primary', href: '/?network=' + network }, + { label: 'Explore Stellar Expert', kind: 'ghost', href: expertBase, external: true }, + ], + }); +} + +const UNREACHABLE_COPY: Record = { + rate_limited: { + title: 'Slow down — rate limit reached', + message: + 'You are browsing faster than the explorer allows. This resets on its own in a moment, no action needed.', + }, + unauthorized: { + title: 'Explorer is not configured', + message: + 'The explorer data key is missing. This is a configuration problem on our side, not your connection.', + }, + network: { + title: 'Could not reach the indexer', + message: + 'We could not reach the Trident indexer from the explorer. Check your connection, then retry.', + }, + timeout: { + title: 'The indexer is taking too long', + message: 'The Trident indexer did not answer in time. Please retry.', + }, + down: { + title: 'The indexer is temporarily unavailable', + message: + 'The Trident indexer is down right now. This is temporary — try again in a moment.', + }, +}; + +function renderUnreachable(reason?: string): void { + const copy = UNREACHABLE_COPY[reason ?? 'down'] ?? UNREACHABLE_COPY.down; + root().innerHTML = panelHtml({ + icon: ICONS.api_unreachable, + title: copy.title, + message: copy.message, + actions: [ + { label: 'Retry', kind: 'primary', action: 'retry' }, + { label: 'Back to search', kind: 'ghost', href: '/?network=' + network }, + ], + }); +} + +function renderNotFound(): void { + root().innerHTML = panelHtml({ + icon: ICONS.not_found, + title: 'Nothing found at this address', + message: + 'We could not find any data at this address. It may have been removed or never existed.', + actions: [{ label: 'Back to search', kind: 'primary', href: '/?network=' + network }], + }); +} + +/* ------------------------------------------------------------------ * + * Table + pagination rendering + * ------------------------------------------------------------------ */ + +function tableShellHtml(): string { + return ` +
+ + + + + + + + + + + + +
TimeLedgerType
+
`; +} + +function renderPagination(hasMore: boolean, nextCursor: string | null): void { + const zone = document.getElementById('pagination-zone'); + if (!zone) return; + if (!hasMore || !nextCursor) { + zone.innerHTML = ''; + return; + } + zone.innerHTML = ` +
+ +
+ + +
+
`; + state.nextCursor = nextCursor; +} + +function populateTopicFilter(events: SorobanEvent[]): void { + const select = document.getElementById('topic0-filter') as HTMLSelectElement | null; + if (!select) return; + const distinct = [...new Set(events.map((e) => e.topics[0] ?? e.event_type))].sort(); + const current = select.value || topic0; + select.innerHTML = + '' + + distinct.map((t) => ``).join(''); + select.value = current; +} + +function renderTable(events: SorobanEvent[], hasMore: boolean, nextCursor: string | null): void { + const zone = root(); + zone.innerHTML = tableShellHtml() + '
'; + const tbody = document.getElementById('events-tbody'); + if (tbody) { + tbody.innerHTML = events.map(eventRowHtml).join(''); + } + renderedUuids = new Set(events.filter((e) => e.id).map((e) => e.id)); + populateTopicFilter(events); + renderPagination(hasMore, nextCursor); +} + +/* ------------------------------------------------------------------ * + * Fetch / render orchestration + * ------------------------------------------------------------------ */ + +const SESSION_STATE = { + status: 'loading' as string, +}; + +function root(): HTMLElement { + return document.getElementById('events-root') as HTMLElement; +} + +let renderedUuids = new Set(); +let busy = false; + +async function refresh(): Promise { + if (busy) return; + busy = true; + root().innerHTML = skeletonHtml(); + try { + const res = await fetch(eventsApiUrl()); + const data = (await res.json()) as ExplorerEventsResponse; + if ( + !res.ok && + data.status !== 'api_unreachable' && + data.status !== 'invalid_contract' && + data.status !== 'not_found' + ) { + // Non-JSON or unexpected failure — treat like an unreachable API. + renderUnreachable('down'); + SESSION_STATE.status = 'api_unreachable'; + return; + } + SESSION_STATE.status = data.status; + switch (data.status) { + case 'ok': + renderTable(data.events ?? [], data.has_more, data.next_cursor); + break; + case 'no_events': + renderNoEvents(Boolean(data.filtered)); + break; + case 'not_indexed': + renderNotIndexed(); + break; + case 'invalid_contract': + renderInvalidContract(); + break; + case 'api_unreachable': + renderUnreachable(data.reason); + break; + case 'not_found': + renderNotFound(); + break; + default: + renderUnreachable('down'); + } + } catch { + renderUnreachable('network'); + SESSION_STATE.status = 'api_unreachable'; + } finally { + busy = false; + } +} + +async function loadMore(): Promise { + const btn = document.getElementById('load-more-btn') as HTMLButtonElement | null; + if (!btn || !state.nextCursor) return; + btn.disabled = true; + btn.textContent = 'Loading…'; + + const p = new URLSearchParams({ network, contractId }); + if (topic0) p.set('topic0', topic0); + if (ledgerFrom) p.set('ledgerFrom', ledgerFrom); + if (ledgerTo) p.set('ledgerTo', ledgerTo); + p.set('cursor', state.nextCursor); + + try { + const res = await fetch(`/api/events.json?${p.toString()}`); + const data = (await res.json()) as ExplorerEventsResponse; + if (!res.ok) throw new Error('load-more failed'); + + const tbody = document.getElementById('events-tbody'); + if (tbody && data.events?.length) { + const fragment = data.events.map(eventRowHtml).join(''); + tbody.insertAdjacentHTML('beforeend', fragment); + for (const ev of data.events) if (ev.id) renderedUuids.add(ev.id); + } + + if (data.has_more && data.next_cursor) { + state.nextCursor = data.next_cursor; + btn.textContent = 'Load more'; + btn.disabled = false; + } else { + btn.remove(); + } + } catch { + const errElt = document.getElementById('load-more-error'); + if (errElt) { + errElt.textContent = "Couldn't load more — check your connection and try again."; + errElt.classList.remove('hidden'); + } + btn.textContent = 'Load more'; + btn.disabled = false; + } +} + +const state = { nextCursor: null as string | null }; + +/* ------------------------------------------------------------------ * + * Live stream (SSE) with automatic reconnect + Last-Event-ID + * ------------------------------------------------------------------ */ + +const MAX_RECONNECT_ATTEMPTS = 10; + +let source: EventSource | null = null; +let reconnectAttempts = 0; +let caughtUpUntil = 0; + +function streamStatusMarkup(): void { + const pill = document.getElementById('stream-status'); + if (!pill) return; + const status = pill.dataset.status ?? ''; + let dot: string; + let label: string; + let hint: string; + switch (status) { + case 'connecting': + dot = 'bg-amber-400 animate-pulse'; + label = 'Connecting to live feed'; + hint = 'Setting up a real-time connection to this contract.'; + break; + case 'open': + dot = 'bg-green-500'; + label = 'Live'; + hint = 'Streaming new events for this contract in real time.'; + break; + case 'reconnecting': + dot = 'bg-amber-400 animate-pulse'; + label = 'Reconnecting…'; + hint = + reconnectAttempts > 1 + ? `Connection dropped — retrying automatically (attempt ${reconnectAttempts}). No events will be skipped.` + : 'Connection dropped — retrying automatically. No events will be skipped.'; + break; + case 'off': + dot = 'bg-red-500'; + label = 'Live feed unavailable'; + hint = + 'The live feed could not be restored automatically. Reconnect anytime to resume — your place in the stream is remembered.'; + break; + case 'paused-filter': + dot = 'bg-gray-500'; + label = 'Live updates paused'; + hint = 'Ledger-range filters stop the live feed. Clear them to watch this contract live.'; + break; + default: + return; + } + const actions = + status === 'off' + ? '' + : ''; + pill.innerHTML = ` + + + ${label} + + ${actions}`; + pill.setAttribute('aria-label', label + ' — ' + hint); + pill.title = hint; +} + +function setStreamStatus(status: 'connecting' | 'open' | 'reconnecting' | 'off' | 'paused-filter'): void { + const pill = document.getElementById('stream-status'); + if (!pill) return; + pill.dataset.status = status; + streamStatusMarkup(); +} + +function showNotice(message: string, persistent = false): void { + const zone = document.getElementById('stream-notice'); + if (!zone) return; + zone.innerHTML = ` +
+ + ${esc(message)} +
`; + if (!persistent) { + setTimeout(() => { + if (zone.dataset.current === message) zone.innerHTML = ''; + }, 7000); + } +} + +function clearNotice(): void { + const zone = document.getElementById('stream-notice'); + if (zone) zone.innerHTML = ''; +} + +function streamUrl(): string { + const p = new URLSearchParams({ network, contractId }); + if (topic0) p.set('topic0', topic0); + return `/api/events/stream?${p.toString()}`; +} + +function startStream(): void { + const pill = document.getElementById('stream-status'); + if (!pill) return; + + if (rangeFiltered) { + setStreamStatus('paused-filter'); + return; + } + + stopStream(); + + source = new EventSource(streamUrl()); + setStreamStatus('connecting'); + + source.addEventListener('open', () => { + reconnectAttempts = 0; + const wasDropped = SESSION_STATE.status === 'stream_reconnecting'; + setStreamStatus('open'); + clearNotice(); + // If we just recovered from a drop, flag the next-seen event as the + // "caught up" marker so the visitor knows nothing was skipped. + caughtUpUntil = wasDropped ? Date.now() + 3000 : 0; + SESSION_STATE.status = 'ok'; + }); + + source.addEventListener('message', (ev: MessageEvent) => { + handleStreamMessage(ev); + }); + + source.addEventListener('gap', () => { + // The upstream buffer was too old to resume exactly (Last-Event-ID fell + // out of retention). Refresh history so nothing looks silently missing. + showNotice( + 'The live feed could not resume from exactly where it stopped, so we refreshed recent history to make sure nothing is missing.', + ); + void refresh(); + }); + + source.onerror = () => { + if (source?.readyState === EventSource.CLOSED) return; + SESSION_STATE.status = 'stream_reconnecting'; + reconnectAttempts += 1; + setStreamStatus('reconnecting'); + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + stopStream(); + setStreamStatus('off'); + } + }; +} + +function stopStream(): void { + if (source) { + source.onerror = null; + source.close(); + source = null; + } +} + +function handleStreamMessage(ev: MessageEvent): void { + let raw: StreamedEvent; + try { + raw = JSON.parse(ev.data as string) as StreamedEvent; + } catch { + return; + } + const event = normalizeStreamedEvent(raw); + if (!event.contract_id || !event.id) return; + + const justCaughtUp = Date.now() <= caughtUpUntil; + + // The table may not be shown yet (e.g. the visitor landed on an empty + // contract). First live event → bring in the full table. + if (SESSION_STATE.status !== 'ok' && SESSION_STATE.status !== 'stream_reconnecting') { + void refresh(); + return; + } + + const zone = root(); + const tbody = document.getElementById('events-tbody'); + if (zone && tbody) { + if (renderedUuids.has(event.id)) return; + renderedUuids.add(event.id); + tbody.insertAdjacentHTML('afterbegin', eventRowHtml(event)); + // Trim the table to a bounded number of live rows so a long session + // doesn't balloon the DOM. + const rows = tbody.querySelectorAll('tr[data-uuid]'); + while (rows.length > 250) rows[rows.length - 1].remove(); + } + + if (justCaughtUp) { + showNotice( + `Live feed restored — showing the latest events, including anything that arrived while you were disconnected.`, + ); + } +} + +/* ------------------------------------------------------------------ * + * Wire-up + * ------------------------------------------------------------------ */ + +function bindCopyButton(): void { + const copyBtn = document.getElementById('copy-address') as HTMLButtonElement | null; + if (!copyBtn) return; + copyBtn.addEventListener('click', async () => { + const addr = copyBtn.dataset.address ?? ''; + try { + await navigator.clipboard.writeText(addr); + const orig = copyBtn.textContent; + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = orig; + }, 1500); + } catch { + /* clipboard unavailable */ + } + }); +} + +function bindRowClicks(): void { + const zone = root(); + zone.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const button = target.closest('button[data-panel-action]'); + if (button) { + const action = button.dataset.panelAction; + if (action === 'retry') void refresh(); + if (action === 'reconnect') startStream(); + if (action === 'clear-filters') { + window.location.href = + window.location.pathname + '?network=' + network; + } + return; + } + if (target.closest('a')) return; + const row = target.closest('tr[data-href]'); + if (row?.dataset.href) { + window.location.href = row.dataset.href; + } + }); +} + +function bindLoadMore(): void { + const zone = root(); + zone.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.closest('#load-more-btn') || target.id === 'load-more-btn') { + void loadMore(); + } + }); +} + +function init(): void { + root().innerHTML = skeletonHtml(); + bindCopyButton(); + bindRowClicks(); + bindLoadMore(); + void refresh().then(() => { + if (SESSION_STATE.status === 'ok' || SESSION_STATE.status === 'no_events' || SESSION_STATE.status === 'not_indexed') { + startStream(); + } + }); +} + +init(); \ No newline at end of file diff --git a/explorer/src/lib/contracts.ts b/explorer/src/lib/contracts.ts new file mode 100644 index 00000000..b41acb6c --- /dev/null +++ b/explorer/src/lib/contracts.ts @@ -0,0 +1,58 @@ +export const CONTRACT_ID_RE = /^C[A-Z2-7]{55}$/; + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const CONTRACT_VERSION_BYTE = 0x10; + +// Stellar strkey payload layout for a contract id: 1 version byte + 32 +// payload bytes + 2 checksum bytes (little-endian) = 35 bytes, base32-encoded +// to 56 chars starting with 'C'. +const STRKEY_CONTRACT_LENGTH = 35; +const STRKEY_PAYLOAD_END = STRKEY_CONTRACT_LENGTH - 2; // 33 + +function crc16XModem(bytes: Uint8Array): number { + let crc = 0; + for (let i = 0; i < bytes.length; i++) { + crc ^= bytes[i] << 8; + for (let j = 0; j < 8; j++) { + crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; + } + } + return crc; +} + +function decodeBase32(value: string): Uint8Array | null { + let bits = 0; + let nBits = 0; + const out: number[] = []; + for (const ch of value) { + const v = BASE32_ALPHABET.indexOf(ch); + if (v === -1) return null; + bits = (bits << 5) | v; + nBits += 5; + if (nBits >= 8) { + nBits -= 8; + out.push((bits >> nBits) & 0xff); + } + } + return new Uint8Array(out); +} + +/** + * Full Stellar strkey validation for Soroban contract addresses. + * + * This is stricter than the Trident API's format check: it verifies the + * base32 charset, the contract version byte, and the CRC16 checksum, so a + * visitor who pastes a typo'd or truncated address gets an honest "that isn't + * a real contract id" state right away instead of a silent empty result. + */ +export function isValidContractId(value: string): boolean { + if (!CONTRACT_ID_RE.test(value)) return false; + const decoded = decodeBase32(value); + if (!decoded || decoded.length !== STRKEY_CONTRACT_LENGTH) return false; + if (decoded[0] !== CONTRACT_VERSION_BYTE) return false; + + const expected = crc16XModem(decoded.subarray(0, STRKEY_PAYLOAD_END)); + const low = decoded[STRKEY_PAYLOAD_END]; + const high = decoded[STRKEY_PAYLOAD_END + 1]; + return low === (expected & 0xff) && high === ((expected >> 8) & 0xff); +} \ No newline at end of file diff --git a/explorer/src/lib/soroban.ts b/explorer/src/lib/soroban.ts new file mode 100644 index 00000000..da7d5cb2 --- /dev/null +++ b/explorer/src/lib/soroban.ts @@ -0,0 +1,115 @@ +import type { Network } from './types'; + +const TESTNET_RPC = + import.meta.env.TRIDENT_TESTNET_SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org'; +const MAINNET_RPC = + import.meta.env.TRIDENT_MAINNET_SOROBAN_RPC_URL ?? 'https://mainnet.sorobanrpc.com'; + +const PROBE_TIMEOUT_MS = 2500; +// How many recent ledgers to scan for on-chain events when deciding whether a +// contract is "not indexed yet" vs "no events yet". ~1000 ledgers ≈ 1.5h at +// Stellar's ~5s close time, which covers freshly deployed contracts. +const PROBE_LEDGER_WINDOW = 1000; +// Short TTL so an empty contract's page doesn't hammer the public RPC on every +// reload, but the state still recovers quickly once the contract goes live. +const CACHE_TTL_MS = 60_000; + +export type OnChainProbeResult = + | { status: 'has_events' } + | { status: 'no_events' } + | { status: 'invalid_contract' } + | { status: 'inconclusive' }; + +interface ProbeCacheEntry { + result: OnChainProbeResult; + at: number; +} + +const probeCache = new Map(); + +function rpcUrl(network: Network): string { + return network === 'mainnet' ? MAINNET_RPC : TESTNET_RPC; +} + +interface RpcError extends Error { + rpcCode?: number; +} + +async function rpcPost(url: string, method: string, params: unknown): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: controller.signal, + }); + if (!res.ok) throw new Error(`RPC HTTP ${res.status}`); + const body = (await res.json()) as { + result?: T; + error?: { code?: number; message?: string }; + }; + if (body.error) { + const err: RpcError = new Error(body.error.message); + err.rpcCode = body.error.code; + throw err; + } + return body.result as T; + } finally { + clearTimeout(timeout); + } +} + +async function runProbe(network: Network, contractId: string): Promise { + try { + const latest = await rpcPost<{ sequence: number }>(rpcUrl(network), 'getLatestLedger', {}); + if (!latest || typeof latest.sequence !== 'number') return { status: 'inconclusive' }; + + const seq = latest.sequence; + const startLedger = Math.max(1, seq - PROBE_LEDGER_WINDOW); + + const result = await rpcPost<{ events?: unknown[] }>(rpcUrl(network), 'getEvents', { + startLedger, + endLedger: seq, + filters: [{ type: 'contract', contractIds: [contractId] }], + limit: 1, + }); + + if (Array.isArray(result?.events)) { + return result.events.length > 0 ? { status: 'has_events' } : { status: 'no_events' }; + } + return { status: 'inconclusive' }; + } catch (err) { + const rpcCode = (err as RpcError)?.rpcCode; + const message = err instanceof Error ? err.message : ''; + if (rpcCode === -32602 && /contract ID .*invalid/i.test(message)) { + // The strkey checksum failed: this is not a real contract address. + return { status: 'invalid_contract' }; + } + // RPC unreachable or degraded — we can't tell; callers fall back to the + // honest "no events" interpretation. + return { status: 'inconclusive' }; + } +} + +/** + * Best-effort on-chain check that tells the explorer whether a contract with + * zero Trident events is simply quiet ("no events yet") or actually emitting + * events on the Stellar network that Trident hasn't indexed ("not indexed"). + * + * Never throws: every failure path returns `inconclusive` so the caller can + * render an honest fallback state. + */ +export async function probeContractOnChain( + network: Network, + contractId: string, +): Promise { + const key = `${network}:${contractId}`; + const cached = probeCache.get(key); + if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.result; + + const result = await runProbe(network, contractId); + probeCache.set(key, { result, at: Date.now() }); + return result; +} \ No newline at end of file diff --git a/explorer/src/lib/state-panel.ts b/explorer/src/lib/state-panel.ts new file mode 100644 index 00000000..4af6cc12 --- /dev/null +++ b/explorer/src/lib/state-panel.ts @@ -0,0 +1,14 @@ +export type PanelState = + | 'no_events' + | 'not_indexed' + | 'invalid_contract' + | 'api_unreachable' + | 'not_found' + | 'info'; + +export interface PanelAction { + label: string; + href: string; + variant?: 'primary' | 'ghost'; + external?: boolean; +} \ No newline at end of file diff --git a/explorer/src/lib/ticker.ts b/explorer/src/lib/ticker.ts new file mode 100644 index 00000000..a86047de --- /dev/null +++ b/explorer/src/lib/ticker.ts @@ -0,0 +1,153 @@ +import { relativeTime, truncate } from './format'; +import type { Network } from './types'; + +/* ------------------------------------------------------------------ * + * Homepage "Recent Events" ticker. + * + * Client-rendered so the page shell paints instantly and the ticker can + * show a deliberate loading skeleton, an honest empty state, and a clear + * "indexer unavailable" panel instead of silently going blank. + * ------------------------------------------------------------------ */ + +const params = new URLSearchParams(window.location.search); +const network: Network = params.get('network') === 'mainnet' ? 'mainnet' : 'testnet'; + +const ul = document.getElementById('event-ticker') as HTMLUListElement | null; +const dot = document.getElementById('ticker-dot') as HTMLSpanElement | null; + +interface TickerEvent { + contract_id: string; + topics: string[]; + event_type: string; + ledger_sequence: number; + ledger_timestamp: string; + id: string; +} + +interface TickerResponse { + status: 'ok' | 'api_unreachable'; + events: TickerEvent[]; + reason?: string; + message?: string; +} + +function setDot(status: 'live' | 'loading' | 'unavailable'): void { + if (!dot) return; + dot.className = 'inline-block w-1.5 h-1.5 rounded-full ml-2 align-middle'; + if (status === 'live') { + dot.classList.add('bg-green-500'); + dot.title = 'Live'; + } else if (status === 'loading') { + dot.classList.add('bg-amber-400', 'animate-pulse'); + dot.title = 'Loading…'; + } else { + dot.classList.add('bg-red-500'); + dot.title = 'Live feed unavailable'; + } +} + +function esc(v: string): string { + return v + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function rowHtml(e: TickerEvent): string { + return ` +
  • + ${esc(truncate(e.contract_id))} + ${esc(e.topics[0] ?? e.event_type)} + + #${e.ledger_sequence} +
  • `.trim(); +} + +function skeletonHtml(): string { + return Array.from( + { length: 5 }, + () => ` +
  • +
    +
    +
    +
  • `, + ).join(''); +} + +function unavailableHtml(message: string): string { + return ` +
  • + +

    ${esc(message)}

    + +
  • `.trim(); +} + +function emptyHtml(): string { + return ` +
  • + No recent events on the Stellar ${network} network right now. Check back shortly. +
  • `.trim(); +} + +let busy = false; + +async function pollTicker(showSkeleton = false): Promise { + if (busy || !ul) return; + busy = true; + if (showSkeleton) { + ul.innerHTML = skeletonHtml(); + setDot('loading'); + } + try { + const res = await fetch(`/api/recent-events.json?network=${network}`); + const data = (await res.json()) as TickerResponse; + if (!res.ok) data.status = 'api_unreachable'; + if (data.status === 'ok') { + setDot('live'); + if (data.events.length === 0) { + ul.innerHTML = emptyHtml(); + } else { + ul.innerHTML = data.events.map(rowHtml).join(''); + } + } else { + setDot('unavailable'); + ul.innerHTML = unavailableHtml(data.message ?? 'The recent-events feed is unavailable right now.'); + } + } catch { + setDot('unavailable'); + ul.innerHTML = unavailableHtml('Could not load recent events. Check your connection.'); + } finally { + busy = false; + } +} + +function bindTickerRetry(): void { + ul?.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.closest('#ticker-retry')) void pollTicker(true); + }); +} + +bindTickerRetry(); +void pollTicker(true); + +const id = window.setInterval(() => void pollTicker(), 5000); +document.addEventListener('visibilitychange', () => { + if (document.hidden) window.clearInterval(id); +}); \ No newline at end of file diff --git a/explorer/src/lib/types.ts b/explorer/src/lib/types.ts index 64274673..b04abde3 100644 --- a/explorer/src/lib/types.ts +++ b/explorer/src/lib/types.ts @@ -18,3 +18,52 @@ export interface ListEventsResponse { } export type Network = 'testnet' | 'mainnet'; + +/** + * The deliberate states the explorer can be in for a contract. Each maps to a + * distinct, honest panel that explains what happened and what to do next. + */ +export type ExplorerState = + | 'loading' + | 'ok' + | 'no_events' + | 'not_indexed' + | 'invalid_contract' + | 'api_unreachable' + | 'not_found'; + +export type UnreachableReason = 'network' | 'down' | 'rate_limited' | 'unauthorized' | 'timeout'; + +/** + * Response envelope returned by the explorer's own /api/events.json route. + * Extends the Trident ListEventsResponse with a classification the client can + * render without reaching into raw error strings. + */ +export interface ExplorerEventsResponse extends ListEventsResponse { + status: ExplorerState; + /** + * Reason for an `api_unreachable` status. Present only when status is + * `api_unreachable`. + */ + reason?: UnreachableReason; + /** + * True when the current request carries a topic0 / ledger-range filter. The + * "no events" panel should then point at the filter, not the contract. + */ + filtered?: boolean; + /** Human-safe message for the current state (never a raw error/stack). */ + message?: string; +} + +/** A single event as delivered by the SSE stream (raw Redis field casing). */ +export interface StreamedEvent { + contract_id: string; + ledger_sequence: string; + ledger_timestamp: string; + transaction_hash: string; + event_index: string; + event_type: string; + topics: string; + data: string; + event_id?: string; +} \ No newline at end of file diff --git a/explorer/src/pages/api/events.json.ts b/explorer/src/pages/api/events.json.ts index 14edf220..a3f8f0b7 100644 --- a/explorer/src/pages/api/events.json.ts +++ b/explorer/src/pages/api/events.json.ts @@ -1,6 +1,17 @@ import type { APIRoute } from 'astro'; -import { listEvents } from '../../lib/api'; -import type { Network } from '../../lib/types'; +import { ApiError, listEvents } from '../../lib/api'; +import { isValidContractId } from '../../lib/contracts'; +import { probeContractOnChain } from '../../lib/soroban'; +import type { ExplorerEventsResponse, Network, UnreachableReason } from '../../lib/types'; + +const jsonHeaders = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: jsonHeaders }); +} export const GET: APIRoute = async ({ url }) => { const rawNetwork = url.searchParams.get('network'); @@ -10,6 +21,19 @@ export const GET: APIRoute = async ({ url }) => { const cursor = url.searchParams.get('cursor') ?? undefined; const rawFrom = url.searchParams.get('ledgerFrom'); const rawTo = url.searchParams.get('ledgerTo'); + const filtered = Boolean(topic0 || rawFrom || rawTo); + + // Validate locally (format + strkey checksum) before spending a request on + // the upstream API, so a typo'd address gets an instant, honest answer. + if (contractId && !isValidContractId(contractId)) { + return json({ + status: 'invalid_contract', + events: [], + has_more: false, + next_cursor: null, + message: 'That address is not a valid Stellar contract id.', + } satisfies ExplorerEventsResponse, 400); + } try { const result = await listEvents({ @@ -21,16 +45,101 @@ export const GET: APIRoute = async ({ url }) => { ledgerTo: rawTo ? Number(rawTo) : undefined, limit: 25, }); - return new Response(JSON.stringify(result), { - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }, - }); + + const base: ExplorerEventsResponse = { ...result, status: 'ok' }; + + // Happy path and the tail of a paginated list. + if (result.events.length > 0 || cursor) return json(base); + + // Empty result for the contract itself: decide between "no events yet" + // (quiet but known) and "not indexed" (emitting on-chain but Trident has + // nothing). Only probe when the visitor is browsing the contract without + // filters, where the distinction actually matters. + if (contractId && !filtered) { + const probe = await probeContractOnChain(network, contractId); + if (probe.status === 'has_events') { + return json({ + ...base, + status: 'not_indexed', + message: + 'This contract is emitting events on the Stellar network, but Trident has not indexed any of them yet.', + } satisfies ExplorerEventsResponse); + } + if (probe.status === 'invalid_contract') { + return json({ + status: 'invalid_contract', + events: [], + has_more: false, + next_cursor: null, + message: 'That address is not a valid Stellar contract id.', + } satisfies ExplorerEventsResponse, 400); + } + } + + return json({ + ...base, + status: 'no_events', + filtered, + message: filtered + ? 'No events match the active filters for this contract.' + : 'Trident has not recorded any events for this contract yet.', + } satisfies ExplorerEventsResponse); } catch (err) { - return new Response(JSON.stringify({ error: 'Failed to fetch events' }), { - status: 502, - headers: { 'Content-Type': 'application/json' }, - }); + if (!(err instanceof ApiError)) { + return json({ + status: 'api_unreachable', + reason: 'network', + events: [], + has_more: false, + next_cursor: null, + message: 'Could not reach the Trident indexer. Please retry.', + } satisfies ExplorerEventsResponse, 502); + } + + const { status, code } = err; + if (status === 400) { + return json({ + status: 'invalid_contract', + events: [], + has_more: false, + next_cursor: null, + message: 'That address is not a valid Stellar contract id.', + } satisfies ExplorerEventsResponse, 400); + } + if (status === 404) { + return json({ + status: 'not_found', + events: [], + has_more: false, + next_cursor: null, + message: "We couldn't find anything at that address.", + } satisfies ExplorerEventsResponse, 404); + } + + let reason: UnreachableReason = 'down'; + if (status === 429 || code === 'RATE_LIMITED') reason = 'rate_limited'; + else if (status === 401 || status === 403 || code === 'UNAUTHORIZED') reason = 'unauthorized'; + else if (status === 0 || code === 'NETWORK') reason = 'network'; + + const messages: Record = { + rate_limited: + 'You are browsing faster than the explorer is allowed to — rate limiting kicked in. It will recover on its own in a moment.', + unauthorized: "The explorer's server key is not configured. This is on us, not you.", + network: 'Could not reach the Trident indexer. Please check your connection and retry.', + timeout: 'The Trident indexer took too long to answer. Please retry.', + down: 'The Trident indexer is temporarily unavailable. Please try again shortly.', + }; + + const httpStatus = + reason === 'rate_limited' ? 429 : reason === 'unauthorized' ? status : 502; + + return json({ + status: 'api_unreachable', + reason, + events: [], + has_more: false, + next_cursor: null, + message: messages[reason], + } satisfies ExplorerEventsResponse, httpStatus); } -}; +}; \ No newline at end of file diff --git a/explorer/src/pages/api/events/stream.ts b/explorer/src/pages/api/events/stream.ts new file mode 100644 index 00000000..b5491f89 --- /dev/null +++ b/explorer/src/pages/api/events/stream.ts @@ -0,0 +1,62 @@ +import type { APIRoute } from 'astro'; +import { streamHeaders, streamUrl } from '../../../lib/api'; +import { isValidContractId } from '../../../lib/contracts'; +import type { Network } from '../../../lib/types'; + +/** + * Server-Sent Events proxy. + * + * The browser opens an EventSource against this route; it forwards to the + * Trident `/v1/events/stream` endpoint so the `X-API-Key`, the `Last-Event-ID` + * resume header, and any SSE `id:` frames all stay on the server hop. The + * browser's EventSource reconnects by itself and re-sends `Last-Event-ID`, + * which this route forwards so no events are skipped after a drop. + */ +export const GET: APIRoute = async ({ url, request }) => { + const rawNetwork = url.searchParams.get('network'); + const network: Network = rawNetwork === 'mainnet' ? 'mainnet' : 'testnet'; + const contractId = url.searchParams.get('contractId') ?? ''; + const topic0 = url.searchParams.get('topic0') ?? ''; + + if (!contractId || !isValidContractId(contractId)) { + return new Response('invalid contract id', { status: 400 }); + } + + const lastEventId = request.headers.get('last-event-id') ?? undefined; + + let upstream: Response; + try { + upstream = await fetch(streamUrl(network, contractId, topic0), { + headers: streamHeaders(lastEventId), + }); + } catch { + return new Response( + JSON.stringify({ + status: 'api_unreachable', + reason: 'network', + message: 'Could not reach the event stream. Retrying automatically.', + }), + { status: 502, headers: { 'Content-Type': 'application/json' } }, + ); + } + + // A refused or unavailable upstream (missing key, indexer down) is not a + // valid stream. The browser's retries will surface a visible reconnecting + // status; when the stream recovers, the reconnects succeed automatically. + if (!upstream.ok || !upstream.body) { + return new Response(upstream.body, { + status: upstream.status, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(upstream.body, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-store', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); +}; \ No newline at end of file diff --git a/explorer/src/pages/api/recent-events.json.ts b/explorer/src/pages/api/recent-events.json.ts index 0ac986d7..f0a1056c 100644 --- a/explorer/src/pages/api/recent-events.json.ts +++ b/explorer/src/pages/api/recent-events.json.ts @@ -1,21 +1,66 @@ import type { APIRoute } from 'astro'; -import { listEvents } from '../../lib/api'; -import type { Network } from '../../lib/types'; +import { ApiError, listEvents } from '../../lib/api'; +import type { Network, UnreachableReason } from '../../lib/types'; + +export interface RecentEventsResponse { + status: 'ok' | 'api_unreachable'; + events: Awaited>['events']; + reason?: UnreachableReason; + message?: string; +} + +const jsonHeaders = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: jsonHeaders }); +} export const GET: APIRoute = async ({ url }) => { const rawNetwork = url.searchParams.get('network'); const network: Network = rawNetwork === 'mainnet' ? 'mainnet' : 'testnet'; + try { const result = await listEvents({ limit: 10, network }); - return new Response(JSON.stringify(result.events), { - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }, - }); - } catch { - return new Response('[]', { - headers: { 'Content-Type': 'application/json' }, - }); + return json({ + status: 'ok', + events: result.events, + } satisfies RecentEventsResponse); + } catch (err) { + if (!(err instanceof ApiError)) { + return json({ + status: 'api_unreachable', + events: [], + reason: 'network', + message: 'Could not reach the Trident indexer. Check your connection.', + } satisfies RecentEventsResponse, 502); + } + + const { status, code } = err; + let reason: UnreachableReason = 'down'; + if (status === 429 || code === 'RATE_LIMITED') reason = 'rate_limited'; + else if (status === 401 || status === 403 || code === 'UNAUTHORIZED') reason = 'unauthorized'; + else if (status === 0 || code === 'NETWORK') reason = 'network'; + + const messages: Record = { + rate_limited: + 'You are browsing faster than the explorer is allowed to — rate limiting kicked in. It will recover on its own in a moment.', + unauthorized: "The explorer's server key is not configured. This is on us, not you.", + network: 'Could not reach the Trident indexer. Please check your connection and retry.', + timeout: 'The Trident indexer took too long to answer. Please retry.', + down: 'The Trident indexer is temporarily unavailable. Please try again shortly.', + }; + + const httpStatus = + reason === 'rate_limited' ? 429 : reason === 'unauthorized' ? status : 502; + + return json({ + status: 'api_unreachable', + events: [], + reason, + message: messages[reason], + } satisfies RecentEventsResponse, httpStatus); } -}; +}; \ No newline at end of file diff --git a/explorer/src/pages/contract/[address]/event/[id].astro b/explorer/src/pages/contract/[address]/event/[id].astro index 6066c1da..2dd5568f 100644 --- a/explorer/src/pages/contract/[address]/event/[id].astro +++ b/explorer/src/pages/contract/[address]/event/[id].astro @@ -1,6 +1,9 @@ --- import Base from '../../../../layouts/Base.astro'; -import { getEvent } from '../../../../lib/api'; +import StatePanel from '../../../../components/StatePanel.astro'; +import type { PanelAction, PanelState } from '../../../../lib/state-panel'; +import { ApiError, getEvent } from '../../../../lib/api'; +import { isValidContractId } from '../../../../lib/contracts'; import { truncate, stellarExpertBase, tryFormatJson } from '../../../../lib/format'; import type { Network } from '../../../../lib/types'; @@ -8,18 +11,78 @@ const { address, id } = Astro.params as { address: string; id: string }; const rawNetwork = Astro.url.searchParams.get('network'); const network: Network = rawNetwork === 'mainnet' ? 'mainnet' : 'testnet'; +const expertBase = stellarExpertBase(network); +const shortAddr = truncate(address); +const shortId = truncate(id, 8, 8); +const backHref = `/contract/${encodeURIComponent(address)}?network=${network}`; +const createActions = (primary: PanelAction): PanelAction[] => [ + primary, + { label: 'Back to events', href: backHref, variant: 'ghost' }, + { label: 'Home', href: `/?network=${network}`, variant: 'ghost' }, +]; + let event: Awaited> | null = null; -let fetchError = ''; +let errorState: { state: PanelState; title: string; message: string } | null = null; + try { - event = await getEvent(id, network); + if (!isValidContractId(address)) { + errorState = { + state: 'invalid_contract', + title: "That doesn't look like a Stellar contract address", + message: + 'The events page you are on uses an address that is not a valid Soroban contract. Copy the full address from Stellar Expert and search again.', + }; + } else { + event = await getEvent(id, network); + } } catch (err) { - fetchError = err instanceof Error ? err.message : 'Event not found'; + if (err instanceof ApiError) { + if (err.status === 404) { + errorState = { + state: 'not_found', + title: 'This event is not in the index', + message: + "Trident could not find this event. It may have been indexed after this ID was collected, or it has rotated out of the index's retention window. Go back to the contract and check its recent events.", + }; + } else if (err.status === 400) { + errorState = { + state: 'not_found', + title: 'That does not look like an event ID', + message: + 'Event IDs look like 58d2bb36-… — a string of hex digits and dashes. The ID in this link does not match, so this event could not be loaded.', + }; + } else if (err.status === 429) { + errorState = { + state: 'api_unreachable', + title: 'Slow down — rate limit reached', + message: + 'You are browsing faster than the explorer allows. This resets on its own in a moment, no action needed.', + }; + } else if (err.status === 401 || err.status === 403) { + errorState = { + state: 'api_unreachable', + title: 'Explorer is not configured', + message: + 'The explorer data key is missing. This is a configuration problem on our side, not your connection.', + }; + } else { + errorState = { + state: 'api_unreachable', + title: 'The indexer is unavailable right now', + message: + 'We could not load this event from the Trident indexer. This is temporary — reload in a moment.', + }; + } + } else { + errorState = { + state: 'api_unreachable', + title: 'Could not reach the indexer', + message: + 'We could not reach the Trident indexer from the explorer. Check your connection, then reload.', + }; + } } -const shortAddr = truncate(address); -const shortId = truncate(id, 8, 8); -const expertBase = stellarExpertBase(network); - const ogTitle = event ? `Event ${shortId} — ${event.topics[0] ?? event.event_type} on ${network}` : `Event ${shortId}`; @@ -38,17 +101,24 @@ const ogDescription = event - {fetchError ? ( -
    - {fetchError} -
    + {errorState ? ( + ) : event ? (
    @@ -154,4 +224,4 @@ const ogDescription = event } catch { /* clipboard unavailable */ } }); } - + \ No newline at end of file diff --git a/explorer/src/pages/contract/[address]/index.astro b/explorer/src/pages/contract/[address]/index.astro index 39b00f6a..9700fc9c 100644 --- a/explorer/src/pages/contract/[address]/index.astro +++ b/explorer/src/pages/contract/[address]/index.astro @@ -1,8 +1,8 @@ --- import Base from '../../../layouts/Base.astro'; -import { listEvents } from '../../../lib/api'; -import { truncate, relativeTime, stellarExpertBase, tryFormatJson } from '../../../lib/format'; -import type { Network, SorobanEvent } from '../../../lib/types'; +import { isValidContractId } from '../../../lib/contracts'; +import { truncate, stellarExpertBase } from '../../../lib/format'; +import type { Network } from '../../../lib/types'; const { address } = Astro.params as { address: string }; const rawNetwork = Astro.url.searchParams.get('network'); @@ -10,44 +10,22 @@ const network: Network = rawNetwork === 'mainnet' ? 'mainnet' : 'testnet'; const topic0Filter = Astro.url.searchParams.get('topic0') ?? ''; const ledgerFrom = Astro.url.searchParams.get('ledgerFrom') ?? ''; const ledgerTo = Astro.url.searchParams.get('ledgerTo') ?? ''; -const cursor = Astro.url.searchParams.get('cursor') ?? ''; -let events: SorobanEvent[] = []; -let hasMore = false; -let nextCursor: string | null = null; -let fetchError = ''; - -try { - const result = await listEvents({ - contractId: address, - network, - topic0: topic0Filter || undefined, - ledgerFrom: ledgerFrom ? Number(ledgerFrom) : undefined, - ledgerTo: ledgerTo ? Number(ledgerTo) : undefined, - cursor: cursor || undefined, - limit: 25, - }); - events = result.events; - hasMore = result.has_more; - nextCursor = result.next_cursor; -} catch (err) { - fetchError = err instanceof Error ? err.message : 'Failed to fetch events'; -} - -// Collect distinct topic_0 values for the filter dropdown -const distinctTypes = [...new Set(events.map((e) => e.topics[0] ?? e.event_type))].sort(); - -// OG meta +// The page shell renders instantly; the events table and every query-dependent +// state (loading, no events, not indexed, invalid, unreachable) are rendered +// client-side so slow queries show a deliberate skeleton instead of a blank +// wait. The frontmatter only derives metadata locals. +const looksValid = isValidContractId(address); const shortAddr = truncate(address); -const ogTitle = `Events for ${shortAddr} on Stellar ${network.charAt(0).toUpperCase() + network.slice(1)}`; -const latestEvent = events[0]; -const ogDescription = latestEvent - ? `Events indexed for this contract. Latest: ${latestEvent.topics[0] ?? latestEvent.event_type} at ledger ${latestEvent.ledger_sequence}.` - : `Soroban contract events on Stellar ${network}.`; +const ogTitle = looksValid + ? `Events for ${shortAddr} on Stellar ${network.charAt(0).toUpperCase() + network.slice(1)}` + : `Events — ${shortAddr}`; +const ogDescription = looksValid + ? `Browse Soroban events indexed by Trident for contract ${address} on ${network}.` + : 'Soroban contract events on Stellar.'; const expertBase = stellarExpertBase(network); -// Build filter URL helper function filterUrl(overrides: Record): string { const params = new URLSearchParams({ network, @@ -112,6 +90,12 @@ function filterUrl(overrides: Record): string {
    + +
    + +
    +
    +
    @@ -124,9 +108,6 @@ function filterUrl(overrides: Record): string { class="px-3 py-1.5 rounded bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-indigo-500" > - {distinctTypes.map((t) => ( - - ))} @@ -173,186 +154,20 @@ function filterUrl(overrides: Record): string { )} - - {fetchError ? ( -
    - Failed to load events: {fetchError} -
    - ) : events.length === 0 ? ( -
    - No events found for this contract{topic0Filter ? ` with type "${topic0Filter}"` : ''}. -
    - ) : ( -
    -
    - - - - - - - - - - - - - {events.map((e) => ( - - - - - - - - - ))} - -
    TimeLedgerType
    - {relativeTime(e.ledger_timestamp)} - - - {e.ledger_sequence} - - - - {e.topics[0] ?? e.event_type} - -
    + +
    +
    - )} + + + import '../../../lib/contract-page'; + \ No newline at end of file diff --git a/explorer/src/pages/index.astro b/explorer/src/pages/index.astro index 9bb190ee..d72db3ae 100644 --- a/explorer/src/pages/index.astro +++ b/explorer/src/pages/index.astro @@ -1,7 +1,5 @@ --- import Base from '../layouts/Base.astro'; -import { listEvents } from '../lib/api'; -import { truncate, relativeTime } from '../lib/format'; import type { Network } from '../lib/types'; const rawNetwork = Astro.url.searchParams.get('network'); @@ -12,14 +10,6 @@ const searchQuery = Astro.url.searchParams.get('q')?.trim(); if (searchQuery) { return Astro.redirect(`/contract/${encodeURIComponent(searchQuery)}?network=${network}`); } - -let recentEvents: Awaited>['events'] = []; -try { - const result = await listEvents({ limit: 10, network }); - recentEvents = result.events; -} catch { - // non-critical — ticker is empty on API failure -} --- - +

    Recent Events

    Updates every 5s
    @@ -150,54 +115,12 @@ try { if (!val) return; e.preventDefault(); const network = - (form.querySelector('input[name="network"]') as HTMLInputElement | null)?.value ?? - 'testnet'; + (form.querySelector('input[name="network"]') as HTMLInputElement | null)?.value ?? 'testnet'; window.location.href = `/contract/${encodeURIComponent(val)}?network=${network}`; }); } - - // Live ticker polling - const network = - new URLSearchParams(window.location.search).get('network') ?? 'testnet'; - - function renderEvent(e: { - contract_id: string; - topics: string[]; - event_type: string; - ledger_sequence: number; - ledger_timestamp: string; - id: string; - }): string { - const trunc = (s: string) => - s.length > 18 ? `${s.slice(0, 8)}...${s.slice(-6)}` : s; - return ` -
  • - ${trunc(e.contract_id)} - ${e.topics[0] ?? e.event_type} - - #${e.ledger_sequence} -
  • `; - } - - async function pollTicker() { - try { - const res = await fetch(`/api/recent-events.json?network=${network}`); - if (!res.ok) return; - const events: Parameters[0][] = await res.json(); - const ul = document.getElementById('event-ticker'); - if (ul && events.length) { - ul.innerHTML = events.map(renderEvent).join(''); - } - } catch { - // silent - } - } - - const id = setInterval(pollTicker, 5000); - document.addEventListener('visibilitychange', () => { - if (document.hidden) clearInterval(id); - }); + + \ No newline at end of file