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..a40c167b 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, server-rendered with pagination + filters. Distinct error/empty states, plus a live SSE feed (status pill + auto-reconnect) | | `/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**: the homepage ticker shows a content skeleton while the recent-events feed loads, 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 cf745273..a966b531 100644 --- a/explorer/src/lib/api.ts +++ b/explorer/src/lib/api.ts @@ -17,6 +17,25 @@ 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; + } +} + export interface QueryEventsParams { contractId?: string; topic0?: string; @@ -46,6 +65,37 @@ async function fetchWithTimeout( } } +/** + * Fetch a JSON body and throw an {@link ApiError} on any non-OK response or + * network failure. The error carries an HTTP status and a machine code so the + * caller can render a deliberate, honest state rather than a raw string. + */ +async function fetchJson(url: string, init?: RequestInit): Promise { + let res: Response; + try { + res = await fetchWithTimeout(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 async function listEvents( params: QueryEventsParams = {}, ): Promise { @@ -60,24 +110,39 @@ export async function listEvents( if (params.cursor) url.searchParams.set("cursor", params.cursor); url.searchParams.set("limit", String(params.limit ?? 25)); - const res = await fetchWithTimeout(url.toString(), { - headers: authHeaders(), - }); - if (!res.ok) throw new Error(`API ${res.status}`); - return (await res.json()) as ListEventsResponse; + return fetchJson(url.toString(), { headers: authHeaders() }); } export async function getEvent( id: string, network: Network = "testnet", ): Promise { - const res = await fetchWithTimeout( + const body = await fetchJson<{ event: SorobanEvent }>( `${baseUrl(network)}/v1/events/${encodeURIComponent(id)}`, - { - headers: authHeaders(), - }, + { headers: authHeaders() }, ); - if (!res.ok) throw new Error(`API ${res.status}`); - const body = (await res.json()) as { event: SorobanEvent }; 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; +} diff --git a/explorer/src/lib/contract-sse.ts b/explorer/src/lib/contract-sse.ts new file mode 100644 index 00000000..b605a222 --- /dev/null +++ b/explorer/src/lib/contract-sse.ts @@ -0,0 +1,209 @@ +import type { Network, StreamedEvent } from './types'; +import { truncate } from './format'; + +/* ------------------------------------------------------------------ * + * Contract page live feed (SSE). + * + * Works on top of the server-rendered page. Manages the stream status + * pill ("connecting / live / reconnecting / off"), auto-reconnects with + * Last-Event-ID so nothing is skipped, and prepends new events to the + * rendered table. + * ------------------------------------------------------------------ */ + +const params = new URLSearchParams(window.location.search); +const network: Network = params.get('network') === 'mainnet' ? 'mainnet' : 'testnet'; +const contractId = decodeURIComponent(window.location.pathname.split('/')[2] ?? ''); +const topic0 = params.get('topic0') ?? ''; + +const MAX_RECONNECT_ATTEMPTS = 10; + +function esc(v: unknown): string { + return String(v ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function parseTopics(raw: string): string[] { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed.map((t) => String(t)); + } catch { + /* ignore */ + } + return []; +} + +let source: EventSource | null = null; +let reconnectAttempts = 0; +let caughtUpUntil = 0; +let wasLive = false; + +function streamUrl(): string { + const p = new URLSearchParams({ network, contractId }); + if (topic0) p.set('topic0', topic0); + return `/api/events/stream?${p.toString()}`; +} + +function setLabel(status: 'connecting' | 'open' | 'reconnecting' | 'off'): void { + const pill = document.getElementById('stream-status'); + if (!pill) return; + let dot: string; + let label: string; + switch (status) { + case 'connecting': + dot = 'bg-amber-400 animate-pulse'; + label = 'Connecting to live feed'; + break; + case 'open': + dot = 'bg-green-500'; + label = 'Live'; + break; + case 'reconnecting': + dot = 'bg-amber-400 animate-pulse'; + label = + reconnectAttempts > 1 + ? `Reconnecting… (attempt ${reconnectAttempts})` + : 'Reconnecting…'; + break; + case 'off': + dot = 'bg-red-500'; + label = 'Live feed unavailable'; + break; + } + const color = + status === 'open' ? 'text-green-300' : status === 'off' ? 'text-red-300' : 'text-amber-300'; + const action = + status === 'off' + ? '' + : ''; + pill.innerHTML = ` + + + ${label} + + ${action}`; + pill.setAttribute( + 'aria-label', + status === 'open' + ? 'Live feed connected' + : status === 'off' + ? 'Live feed unavailable' + : label, + ); + pill.title = label; +} + +function showNotice(message: string): void { + const zone = document.getElementById('stream-notice'); + if (!zone) return; + zone.innerHTML = ` +
+ + ${esc(message)} +
`; +} + +function clearNotice(): void { + const zone = document.getElementById('stream-notice'); + if (zone) zone.innerHTML = ''; +} + +function liveRowHtml(e: StreamedEvent): string { + const topics = parseTopics(e.topics); + const id = e.event_id ?? ''; + const href = `/contract/${encodeURIComponent(e.contract_id || contractId)}/event/${encodeURIComponent(id)}?network=${network}`; + return ` + + just now + ${esc(e.ledger_sequence)} + ${esc(topics[0] ?? e.event_type)} + ${topics[1] ? esc(truncate(topics[1], 12, 8)) : '—'} + ${e.transaction_hash ? esc(truncate(e.transaction_hash, 8, 6)) : '—'} + ${e.data ? esc(String(e.data).slice(0, 60)) : '—'} + `.trim(); +} + +function startStream(): void { + stopStream(); + source = new EventSource(streamUrl()); + setLabel('connecting'); + + source.addEventListener('open', () => { + const resumed = wasLive; + reconnectAttempts = 0; + wasLive = true; + setLabel('open'); + clearNotice(); + caughtUpUntil = resumed ? Date.now() + 3000 : 0; + }); + + source.addEventListener('message', (ev: MessageEvent) => { + let raw: StreamedEvent; + try { + raw = JSON.parse(ev.data as string) as StreamedEvent; + } catch { + return; + } + if (!raw.contract_id || !raw.event_id) return; + + const tbody = document.getElementById('events-tbody'); + if (!tbody) return; + + const seen = Array.from(tbody.querySelectorAll('tr[data-href]')).some((r) => + (r as HTMLElement).dataset.href?.includes(encodeURIComponent(raw.event_id ?? '')), + ); + if (seen) return; + + tbody.insertAdjacentHTML('afterbegin', liveRowHtml(raw)); + const rows = tbody.querySelectorAll('tr[data-href]'); + while (rows.length > 250) rows[rows.length - 1].remove(); + + if (Date.now() <= caughtUpUntil) { + showNotice( + 'Live feed restored — showing the latest events, including anything that arrived while you were disconnected.', + ); + } + }); + + source.addEventListener('gap', () => { + showNotice( + 'The live feed could not resume from exactly where it stopped, so refresh to make sure nothing is missing.', + ); + }); + + source.onerror = () => { + if (source?.readyState === EventSource.CLOSED) return; + reconnectAttempts += 1; + setLabel('reconnecting'); + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + stopStream(); + setLabel('off'); + } + }; +} + +function stopStream(): void { + if (source) { + source.onerror = null; + source.close(); + source = null; + } +} + +document.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (target.closest('#stream-reconnect')) { + startStream(); + return; + } + const row = target.closest('tr[data-href]'); + if (row?.dataset.href && !target.closest('a')) { + window.location.href = row.dataset.href; + } +}); + +startStream(); 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..dc539570 --- /dev/null +++ b/explorer/src/lib/ticker.ts @@ -0,0 +1,154 @@ +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(), 10000); +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 efbf1381..341e67cc 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": "public, max-age=5, s-maxage=5", +}; + +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,19 +45,107 @@ 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": "public, max-age=5, s-maxage=5", - }, - }); + + 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", - "Cache-Control": "public, max-age=2", - }, - }); + 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, + ); } }; 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 e8db62d8..96147770 100644 --- a/explorer/src/pages/api/recent-events.json.ts +++ b/explorer/src/pages/api/recent-events.json.ts @@ -1,24 +1,72 @@ 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": "public, max-age=10, s-maxage=10", +}; + +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": "public, max-age=10, s-maxage=10", - }, - }); - } catch { - return new Response("[]", { - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, max-age=5", - }, - }); + 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, + ); } }; 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 c8a2566b..17770ce4 100644 --- a/explorer/src/pages/contract/[address]/index.astro +++ b/explorer/src/pages/contract/[address]/index.astro @@ -1,10 +1,12 @@ --- import Base from '../../../layouts/Base.astro'; import ErrorState from '../../../components/ErrorState.astro'; -import EmptyState from '../../../components/EmptyState.astro'; +import { isValidContractId } from '../../../lib/contracts'; import { listEvents } from '../../../lib/api'; +import { probeContractOnChain } from '../../../lib/soroban'; import { truncate, relativeTime, stellarExpertBase, tryFormatJson } from '../../../lib/format'; -import { ErrorType, getErrorState, classifyError } from '../../../lib/errors'; +import { ErrorType, getErrorState } from '../../../lib/errors'; +import type { ErrorState as ErrorStateType } from '../../../lib/errors'; import type { Network, SorobanEvent } from '../../../lib/types'; const { address } = Astro.params as { address: string }; @@ -14,55 +16,83 @@ 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') ?? ''; +const filtered = Boolean(topic0Filter || ledgerFrom || ledgerTo); let events: SorobanEvent[] = []; let hasMore = false; let nextCursor: string | null = null; -let errorState = null as ReturnType | null; - -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; +let errorState = null as ErrorStateType | null; + +if (!isValidContractId(address)) { + errorState = { + type: ErrorType.NOT_FOUND, + title: "That doesn't look like a Stellar contract address", + message: + 'A Soroban contract address is a 56-character string starting with C (for example C…) and ends in a valid checksum. Double-check for typos, copy the full address, and search again.', + actionText: 'Back to search', + actionUrl: '/', + retryable: false, + }; +} else { + 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, + }); - // No events at all - if (events.length === 0 && !cursor) { - errorState = getErrorState(ErrorType.NO_DATA, { address }); - } - // Empty result with filters - else if (events.length === 0 && (topic0Filter || ledgerFrom || ledgerTo)) { - errorState = getErrorState(ErrorType.EMPTY_RESULT, { - filter: topic0Filter || `ledgers ${ledgerFrom}-${ledgerTo}`, - clearUrl: `/contract/${address}?network=${network}`, + events = result.events; + hasMore = result.has_more; + nextCursor = result.next_cursor; + + // No events at all: distinguish "no events yet" (quiet, but being watched + // live) from "not indexed yet" (emitting on-chain, Trident hasn't caught + // up) via a best-effort Soroban RPC probe. Only probe unbrowsed contracts. + if (events.length === 0 && !cursor && !filtered) { + const probe = await probeContractOnChain(network, address); + if (probe.status === 'has_events') { + errorState = getErrorState(ErrorType.INDEXER_BEHIND, { + address, + currentLedger: 'checking', + lastIndexedLedger: 'unknown', + }); + } else { + errorState = { + type: ErrorType.NO_DATA, + title: 'No events yet', + message: + 'Trident has not recorded any events for this contract yet. If it was just deployed, its events will appear here as soon as it emits one — this page is also streaming live, so nothing will be missed.', + actionText: 'Back to search', + actionUrl: '/', + retryable: true, + }; + } + } + // Empty result with filters + else if (events.length === 0 && (topic0Filter || ledgerFrom || ledgerTo)) { + errorState = getErrorState(ErrorType.EMPTY_RESULT, { + filter: topic0Filter || `ledgers ${ledgerFrom}-${ledgerTo}`, + clearUrl: `/contract/${address}?network=${network}`, + }); + } + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + let errorType = ErrorType.API_ERROR; + const status = error.message.match(/API (\d+)/)?.[1]; + if (status === '404') errorType = ErrorType.NOT_FOUND; + else if (status === '429') errorType = ErrorType.RATE_LIMITED; + else if (error.name === 'AbortError') errorType = ErrorType.TIMEOUT; + errorState = getErrorState(errorType, { + error, + statusCode: status, + timeout: 30, + address, }); } -} catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - - // Classify the error - let errorType = classifyError(error); - - // Special handling for timeout - if (error.message.includes('AbortError') || error.name === 'AbortError') { - errorType = ErrorType.TIMEOUT; - } - - errorState = getErrorState(errorType, { - error, - statusCode: error.message.match(/API (\d+)/)?.[1], - timeout: 30, - address, - }); } // Collect distinct topic_0 values for the filter dropdown @@ -118,7 +148,7 @@ function filterUrl(overrides: Record): string { href={`${expertBase}/contract/${address}`} target="_blank" rel="noopener noreferrer" - class="hover:text-white underline underline-offset-2" + class="hover:text-white underline underline-offset-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded" > Stellar Expert ↗ @@ -143,6 +173,14 @@ function filterUrl(overrides: Record): string {
    + + {isValidContractId(address) && !filtered && ( +
    + + +
    + )} + {errorState && !events.length && ( @@ -299,317 +337,6 @@ function filterUrl(overrides: Record): string { ) : null} -// 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 -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 expertBase = stellarExpertBase(network); - -// Build filter URL helper -function filterUrl(overrides: Record): string { - const params = new URLSearchParams({ - network, - ...(topic0Filter ? { topic0: topic0Filter } : {}), - ...(ledgerFrom ? { ledgerFrom } : {}), - ...(ledgerTo ? { ledgerTo } : {}), - }); - for (const [k, v] of Object.entries(overrides)) { - if (v) params.set(k, v); - else params.delete(k); - } - return `?${params.toString()}`; -} ---- - - - - - - -
    -
    -

    - {address} -

    -
    - - Stellar Expert ↗ - - - {network} - -
    -
    - - -
    - - -
    - - -
    - - -
    - -
    - - -
    - -
    - - -
    - - - - {(topic0Filter || ledgerFrom || ledgerTo) && ( - - Clear - - )} -
    - - - {events.length > 0 ? ( -
    -
    - - - - - - - - - - - - - {events.map((e) => ( - - - - - - - - - ))} - -
    TimeLedgerType
    - {relativeTime(e.ledger_timestamp)} - - - {e.ledger_sequence} - - - - {e.topics[0] ?? e.event_type} - -
    -
    - - -
    - {cursor ? ( - - ← Back to first page - - ) : } - - {hasMore && nextCursor && ( - - )} -
    -
    - )} - - + import '../../../lib/contract-sse'; + \ No newline at end of file diff --git a/explorer/src/pages/index.astro b/explorer/src/pages/index.astro index ce66a1f1..7cdca9ee 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

    @@ -102,36 +92,12 @@ try {
    @@ -162,49 +128,8 @@ try { 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, 10000); - document.addEventListener('visibilitychange', () => { - if (document.hidden) clearInterval(id); - }); + + \ No newline at end of file