Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 111 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ src/
### Prerequisites

- **Node.js ≥ 18**
- The [Octraban backend](https://github.com/octraban/octraban_backend) running locally (provides the indexer API the UI reads from), or a reachable indexer URL
- The **[octraban_backend](https://github.com/pharuq411/octraban_backend)** indexer running locally on `:3001` (provides all `/api/*` data the UI reads from). Clone and start it first — the frontend will show a prominent banner until the backend is reachable.
- A [Freighter](https://www.freighter.app/) wallet for signing (optional, for write flows)

### Installation
Expand Down Expand Up @@ -165,18 +165,121 @@ The defaults point at **Stellar testnet**, so a fresh checkout talks to the live
Octraban is split across three repositories:

- **octraban_frontend** _(this repo)_ — the explorer & developer workspace UI.
- **[octraban_backend](https://github.com/octraban/octraban_backend)** — the API + indexer that ingests on-chain data and serves it to the UI (`:3001`).
- **[octraban_backend](https://github.com/pharuq411/octraban_backend)** — the API + indexer that ingests on-chain data and serves it to the UI (`:3001`).
- **[octraban_contract](https://github.com/octraban/octraban_contract)** — the Soroban smart contracts (explorer registry + ticket), deployed to testnet.

### Port topology

| Service | Default port | Notes |
| -------------------------------- | ------------ | -------------------------------------------------- |
| **octraban_frontend** (Vite dev) | `:5173` | `npm run dev`; production build served on `:3000` (nginx / Docker) |
| **octraban_backend** (indexer) | `:3001` | All `/api/*` traffic is proxied here |
| **Soroban RPC** | n/a | `VITE_SOROBAN_RPC_URL` (testnet default) |

```
Freighter ─┐
octraban_frontend ──▶ octraban_backend (indexer :3001) ──▶ Soroban RPC / testnet
(this repo) ▲
octraban_contract (deployed testnet contracts)
Browser (user)
octraban_frontend :5173 dev / :3000 prod
│ all /api/* requests
octraban_backend (indexer) :3001 ◀─── start this first
Soroban RPC / Stellar testnet
octraban_contract (deployed testnet contracts)
```

In development, Vite proxies every `/api/*` request to `http://localhost:3001` (configured in `vite.config.ts`). The production Docker image uses nginx for the same proxy. **The frontend will display a banner and all data views will fail if the backend is not running.**

---

## 🔌 Backend API contract

> All endpoints are served by the [octraban_backend](https://github.com/pharuq411/octraban_backend) indexer on port **3001**.
> In development the Vite proxy (`server.proxy` in `vite.config.ts`) forwards `/api → http://localhost:3001` so the frontend always uses relative `/api/*` paths.

### Health

| Method | Path | Description |
| ------ | ------------ | ------------------------------------------------ |
| GET | `/api/health` | Liveness check — 200 OK when the indexer is up. Used by `BackendStatusBanner` on startup. |

### Explorer — read endpoints

| Method | Path | Description |
| ------ | ---- | ----------- |
| GET | `/api/events` | Paginated event list (`?contract=&fn=&page=&type=`) |
| GET | `/api/events/:seq` | Single decoded event |
| GET | `/api/events/:seq/zk-costs` | CAP-0080 ZK host-function telemetry for an event |
| GET | `/api/search` | Universal search (`?q=&limit=`) — contracts, events, wallets |
| GET | `/api/contracts/:id` | Contract metadata + ABI |
| GET | `/api/contracts/:id/abi` | Download ABI as JSON blob |
| GET | `/api/contracts/:id/spec-full` | Full contract spec (functions + custom types) |
| GET | `/api/contracts/:id/migration-status` | SEP-49 migration state |
| GET | `/api/contracts/:id/roles` | Privileged role addresses |
| GET | `/api/contracts/:id/network-comparison` | State across networks |
| GET | `/api/contracts/:id/address-graph` | Address relationship graph data |
| GET | `/api/contracts/:id/circuit-breaker` | Circuit-breaker pause status |
| GET | `/api/contracts/:id/rwa-metadata` | Real-world asset token metadata |
| GET | `/api/contracts/:id/source-verifications` | Multi-sig source verifications (`?wasm_hash=`) |
| GET | `/api/contracts/:id/ttl` | Live TTL for instance + code entries |
| GET | `/api/contracts/:id/state-diffs` | Storage state-diff timeline (`?key=`) |
| GET | `/api/contracts/:id/quorum-freeze` | CAP-0077 quorum-freeze status |
| GET | `/api/burn-alerts` | Burn-rate alerts (`?contract=`) |
| GET | `/api/wallet/:address` | Events for a wallet address |
| GET | `/api/contract-graph` | Global contract dependency graph (`?limit=`) |
| GET | `/api/v1/contracts/:id/events-deep` | Events where contract appears directly or as sub-invocation (`?page=`) |

### Transactions & sub-invocations

| Method | Path | Description |
| ------ | ---- | ----------- |
| GET | `/api/transactions/:txHash/sub-invocations` | Sub-invocation tree for a transaction |
| GET | `/api/transactions/:txHash/status` | Transaction status (polling) |
| GET | `/api/transactions/:txHash/status/stream` | Live status via Server-Sent Events |
| GET | `/api/transactions/:txHash/call-path-metrics` | Analytical call-path metrics |
| GET | `/api/transactions/compare` | Cross-transaction tree diff (`?a=&b=`) |
| GET | `/api/sub-invocations/search` | Filtered sub-invocation search (8+ filter params) |
| GET | `/api/sub-invocations/analytics` | Aggregate analytics across all sub-invocations |
| GET | `/api/sub-invocations/stream` | SSE feed of live sub-invocations (`?contract=&function=`) |

### Write & simulation

| Method | Path | Description |
| ------ | ---- | ----------- |
| POST | `/api/contracts/:id/source-verifications` | Submit a source verification signature |
| POST | `/api/simulate` | Simulate a single contract invocation |
| POST | `/api/batch/simulate` | Batch multi-call simulation |
| POST | `/api/batch/estimate-gas` | Batch gas estimation |
| POST | `/api/batch/optimize` | Batch call-order optimisation |
| POST | `/api/batch/validate` | Batch call validation |

### Sandbox persistence

| Method | Path | Description |
| ------ | ---- | ----------- |
| POST | `/api/sandbox` | Save a sandbox session |
| GET | `/api/sandbox/:id` | Load a sandbox session |
| DELETE | `/api/sandbox/:id` | Delete a sandbox session |
| GET | `/api/sandboxes` | List sandbox sessions (`?limit=&offset=`) |

### Operations & admin

| Method | Path | Description |
| ------ | ---- | ----------- |
| GET | `/api/rpc-metrics` | RPC layer latency, throughput, and circuit-breaker state |
| GET | `/api/admin/analytics/rate-limit-hits` | Rate-limit hit counts (`?minutes=60`) |
| GET | `/api/admin/analytics/top-users` | Top rate-limit offenders (`?window=`) |
| GET | `/api/admin/analytics/violation-heatmap` | Rate-limit heatmap |
| GET | `/api/admin/analytics/upgrade-recommendations` | Upgrade advisories |
| GET | `/api/setup/doctor` | Backend diagnostic / health check |
| POST | `/api/setup/test-db` | Test database connection |
| POST | `/api/setup/save-config` | Persist backend configuration |
| POST | `/api/setup/db-init` | Initialise the database |

---

## 🤝 Contributing
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import Nav from "./components/Nav";
import ErrorBoundary from "./components/ErrorBoundary";
import BackendStatusBanner from "./components/BackendStatusBanner";

const Home = lazy(() => import("./pages/Home"));
const ContractPage = lazy(() => import("./pages/ContractPage"));
Expand Down Expand Up @@ -31,6 +32,7 @@ function Fallback() {
export default function App() {
return (
<ErrorBoundary>
<BackendStatusBanner />
<Nav />
<main style={{ maxWidth: 1100, margin: "0 auto", padding: "24px 16px" }}>
<Suspense fallback={<Fallback />}>
Expand Down
150 changes: 150 additions & 0 deletions src/components/BackendStatusBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* BackendStatusBanner
*
* Performs a lightweight health-check against the indexer backend on mount
* (and every POLL_INTERVAL ms thereafter). Shows a dismissible, non-blocking
* warning banner while the backend is unreachable so users get a clear signal
* instead of silent fetch failures scattered across the app.
*
* Health endpoint: GET /api/health
* The banner is hidden once the backend responds with any 2xx status.
*/

import { useEffect, useRef, useState } from "react";

/** How often to re-check when the backend is unreachable (ms). */
const POLL_INTERVAL = 15_000;

/** Resolved from VITE_INDEXER_URL at build time; falls back to localhost. */
const INDEXER_URL =
(import.meta as unknown as { env: Record<string, string> }).env
.VITE_INDEXER_URL ?? "http://localhost:3001";

type Status = "checking" | "ok" | "unreachable";

async function checkHealth(): Promise<boolean> {
try {
const res = await fetch("/api/health", { signal: AbortSignal.timeout(4000) });
return res.ok;
} catch {
return false;
}
}

export default function BackendStatusBanner() {
const [status, setStatus] = useState<Status>("checking");
const [dismissed, setDismissed] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
let cancelled = false;

async function poll() {
const reachable = await checkHealth();
if (cancelled) return;

setStatus(reachable ? "ok" : "unreachable");

// Keep polling while unreachable so the banner auto-hides when the
// backend comes back up.
if (!reachable) {
timerRef.current = setTimeout(poll, POLL_INTERVAL);
}
}

poll();

return () => {
cancelled = true;
if (timerRef.current != null) clearTimeout(timerRef.current);
};
}, []);

// When the backend recovers, un-dismiss so the banner (re-)disappears cleanly.
useEffect(() => {
if (status === "ok") setDismissed(false);
}, [status]);

if (status !== "unreachable" || dismissed) return null;

return (
<div
role="alert"
aria-live="polite"
data-testid="backend-status-banner"
style={{
position: "sticky",
top: 0,
zIndex: 1000,
display: "flex",
alignItems: "flex-start",
gap: 12,
padding: "12px 20px",
background: "#3a1a0a",
borderBottom: "1px solid var(--red, #f85149)",
color: "var(--text, #e6edf3)",
}}
>
{/* Icon */}
<span
aria-hidden="true"
style={{ fontSize: 18, lineHeight: 1, marginTop: 1, flexShrink: 0 }}
>
</span>

{/* Message */}
<div style={{ flex: 1, fontSize: 13, lineHeight: 1.6 }}>
<span
style={{
fontWeight: 700,
color: "var(--red, #f85149)",
marginRight: 8,
}}
>
Indexer unreachable
</span>
The Octraban backend could not be reached at{" "}
<code
style={{
fontSize: 12,
padding: "1px 5px",
borderRadius: 4,
background: "rgba(255,255,255,0.08)",
}}
>
{INDEXER_URL}
</code>
. Contract data, events, and search results will not load until the
backend is running. See the{" "}
<a
href="https://github.com/pharuq411/octraban_frontend#-how-it-fits-together"
target="_blank"
rel="noreferrer noopener"
style={{ color: "var(--accent, #58a6ff)" }}
>
setup guide
</a>{" "}
for instructions.
</div>

{/* Dismiss */}
<button
onClick={() => setDismissed(true)}
aria-label="Dismiss backend unreachable warning"
style={{
flexShrink: 0,
background: "none",
border: "none",
cursor: "pointer",
color: "var(--muted, #8b949e)",
fontSize: 18,
lineHeight: 1,
padding: "0 2px",
}}
>
</button>
</div>
);
}
39 changes: 36 additions & 3 deletions src/components/ContractDependencyGraph3D.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,42 @@ export default function ContractDependencyGraph3D() {
return <p style={{ color: "var(--muted)", padding: 24 }}>Loading graph…</p>;
if (error)
return (
<p style={{ color: "#ef4444", padding: 24 }}>
Failed to load contract graph.
</p>
<div
style={{
textAlign: "center",
padding: "48px 24px",
background: "#fef2f2",
borderRadius: 8,
border: "1px solid #fecaca",
}}
data-testid="error-state"
>
<div style={{ fontSize: 32, marginBottom: 8 }}>⚠️</div>
<p style={{ color: "#991b1b", fontSize: 14, marginBottom: 0 }}>
Failed to load contract graph. The indexer backend may be unavailable.
</p>
</div>
);

if (!initial || initial.nodes.length === 0)
return (
<div
style={{
textAlign: "center",
padding: "48px 24px",
color: "var(--muted)",
}}
data-testid="empty-state"
>
<div style={{ fontSize: 40, marginBottom: 12 }}>📭</div>
<h3 style={{ fontSize: 16, fontWeight: 600, color: "var(--text)", marginBottom: 8 }}>
No graph data yet
</h3>
<p style={{ fontSize: 14 }}>
No cross-contract calls have been indexed yet. Check back once some
Soroban transactions have been processed.
</p>
</div>
);

const nodeCount = initial?.nodes.length ?? 0;
Expand Down
28 changes: 28 additions & 0 deletions src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
interface EmptyStateProps {
title: string;
message?: string;
icon?: string;
}

export default function EmptyState({
title,
message,
icon = "📭",
}: EmptyStateProps) {
return (
<div
style={{
textAlign: "center",
padding: "48px 24px",
color: "var(--muted)",
}}
data-testid="empty-state"
>
<div style={{ fontSize: 40, marginBottom: 12 }}>{icon}</div>
<h3 style={{ fontSize: 16, fontWeight: 600, color: "var(--text)", marginBottom: 8 }}>
{title}
</h3>
{message && <p style={{ fontSize: 14 }}>{message}</p>}
</div>
);
}
Loading
Loading