Skip to content
6 changes: 6 additions & 0 deletions explorer/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 18 additions & 1 deletion explorer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
Expand Down
68 changes: 68 additions & 0 deletions explorer/src/components/StatePanel.astro
Original file line number Diff line number Diff line change
@@ -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<PanelState, string> = {
no_events:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-9 h-9 mx-auto" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>',
not_indexed:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-9 h-9 mx-auto" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>',
invalid_contract:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-9 h-9 mx-auto" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>',
api_unreachable:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-9 h-9 mx-auto" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v6a4 4 0 014 4h10a4 4 0 014-4V7a4 4 0 00-4-4H7a4 4 0 00-4 4z" /><path stroke-linecap="round" stroke-linejoin="round" d="M12 11v4m0 0h.01" /></svg>',
not_found:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-9 h-9 mx-auto" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M17 11a6 6 0 11-12 0 6 6 0 0112 0z" /></svg>',
info:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-9 h-9 mx-auto" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 11v5m0-9h.01M12 21a9 9 0 110-18 9 9 0 010 18z" /></svg>',
};
---

<div
class="rounded-xl border border-gray-800 bg-gray-900/60 px-6 py-12 text-center"
role="status"
data-state={state}
>
<div class="text-indigo-300" set:html={icons[state]}></div>
<h3 class="text-base font-semibold text-white mt-4">{title}</h3>
<p class="text-sm text-gray-400 max-w-xl mx-auto mt-2 leading-relaxed">{message}</p>
{
actions.length > 0 && (
<div class="mt-7 flex flex-wrap items-center justify-center gap-3">
{
actions.map((action) =>
action.variant === 'primary' ? (
<a
href={action.href}
class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors"
{...(action.external
? { target: '_blank', rel: 'noopener noreferrer' }
: {})}
>
{action.label}
</a>
) : (
<a
href={action.href}
class="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"
{...(action.external
? { target: '_blank', rel: 'noopener noreferrer' }
: {})}
>
{action.label}
</a>
),
)
}
</div>
)
}
</div>
87 changes: 76 additions & 11 deletions explorer/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<T>(url: string, init?: RequestInit): Promise<T> {
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<ListEventsResponse> {
Expand All @@ -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<ListEventsResponse>(url.toString(), { headers: authHeaders() });
}

export async function getEvent(
id: string,
network: Network = "testnet",
): Promise<SorobanEvent> {
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<string, string> = {
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;
}
Loading