diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml new file mode 100644 index 0000000..98459f6 --- /dev/null +++ b/.github/workflows/docs-ci.yml @@ -0,0 +1,58 @@ +name: Documentation CI + +on: + push: + branches: [main, dev] + paths: + - 'docs/site/**' + - '.github/workflows/docs-ci.yml' + pull_request: + branches: [main, dev] + paths: + - 'docs/site/**' + - '.github/workflows/docs-ci.yml' + +jobs: + validate-docs: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: 'npm' + cache-dependency-path: 'sdk/typescript/package-lock.json' + + # The examples import @trident-indexer/sdk via a file: dependency, which + # resolves to the SDK's built dist/ — and dist/ is gitignored, so without + # this step tsc fails with "Cannot find module '@trident-indexer/sdk'". + # Same reason the sdk-react job in ci.yml builds the TypeScript SDK + # before installing. + - name: Build the TypeScript SDK + working-directory: sdk/typescript + run: npm ci && npm run build + + - name: Validate TypeScript Code Examples + run: | + cd docs/site + npm install --no-save typescript @trident-indexer/sdk@file:../../sdk/typescript + npx tsc --noEmit + + # Only the .mdx files are checked. The previous 'docs/site/**/*.json' glob + # matched no tracked file in this repo — `git ls-files 'docs/site/**/*.json'` + # returns nothing — so in practice it only ever reached the dependency + # metadata npm unpacks into docs/site/node_modules during the step above, + # and failed the job on other people's rotted author URLs and on + # placeholder links inside TypeScript's localised diagnostic files. + # + # Remaining exclusions (unpublished product pages) live in .lycheeignore + # at the repo root, which lychee reads automatically. + - name: Check for Broken Links + uses: lycheeverse/lychee-action@2b973e86fc7b1f6b36a93795fe2c9c6ae1118621 # v1 + with: + args: --verbose --no-progress 'docs/site/**/*.mdx' + fail: true diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 0000000..90f6eb6 --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,23 @@ +# URLs the docs link checker must not fail on. +# One regex per line, matched against the URL. lychee reads this file +# automatically from the repo root. +# +# Note these are URL patterns, not paths — a bare directory name here matches +# nothing. Files are excluded by narrowing the globs in +# .github/workflows/docs-ci.yml instead. + +# Product pages that are not published yet. The marketing site resolves, but +# these paths do not exist, so the checker fails on links the docs are correct +# to contain — they are where a reader should go once the pages ship. Remove +# each line as the corresponding page goes live. +https://trident\.telocel\.com/signup +https://trident\.telocel\.com/pricing + +# The API root serves no HTML document; only the versioned endpoints beneath it +# do, and those are covered by the contract tests rather than a link check. +https://api\.trident\.telocel\.com/?$ + +# GitHub Discussions is not enabled for this repository (has_discussions is +# false), so this 404s. Delete this line if Discussions is turned on, or drop +# the link from the docs. +https://github\.com/Telocel-Labs/Trident/discussions diff --git a/docs/site/api-reference/authentication.mdx b/docs/site/api-reference/authentication.mdx new file mode 100644 index 0000000..b8f1de5 --- /dev/null +++ b/docs/site/api-reference/authentication.mdx @@ -0,0 +1,116 @@ +--- +title: "Authentication" +description: "How API key authentication works in Trident — the X-API-Key header, 401 vs 403 responses, and managing keys in self-hosted deployments." +--- + +Every Trident API endpoint (except `GET /v1/health`) requires authentication via an API key. + +--- + +## The X-API-Key header + +Pass your API key as the `X-API-Key` header on every request: + +```bash +curl https://api.trident.telocel.com/v1/events \ + -H "X-API-Key: tdk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +``` + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); +// The SDK sends X-API-Key automatically on every request. +``` + + +Never expose your API key in client-side code, public repositories, or browser environments. Always read it from an environment variable or a secrets manager. + + +--- + +## Getting an API key + +### Hosted API + +Sign up at [trident.telocel.com](https://trident.telocel.com/signup). Free tier keys are issued immediately. Each key is tied to a rate-limit tier (see [Rate Limiting](/api-reference/rate-limiting)). + +### Self-hosted + +API keys in a self-hosted deployment are managed via environment variables: + +1. **Generate a salt** (do this once per deployment): + ```bash + openssl rand -hex 32 + # → a3f7c2b1... + ``` + +2. **Set the salt in `.env`**: + ```bash + API_KEY_SALT=a3f7c2b1... + ``` + +3. **Hash your API keys** using HMAC-SHA256 with the salt: + ```bash + echo -n "your-raw-api-key" | openssl dgst -sha256 -hmac "a3f7c2b1..." + # → SHA2-256(stdin)= 9c1d3b... + ``` + +4. **Set the hashes in `.env`**: + ```bash + API_KEY_HASHES=9c1d3b...,another-hash-here + ``` + +The API compares incoming `X-API-Key` values (HMAC-hashed with `API_KEY_SALT`) against the list in `API_KEY_HASHES` using a constant-time comparison. + +--- + +## Error responses + +### 401 Unauthorized + +Returned when the `X-API-Key` header is missing or the key value is not recognised. + +```json +{ + "error": { + "code": "UNAUTHORIZED", + "message": "missing or invalid API key" + } +} +``` + +**Causes:** +- Header omitted entirely +- Key value is malformed (not the correct length/format) +- Key has been revoked + +### 403 Forbidden + +Returned when the key is valid but does not have permission to access the requested resource or tier. + +```json +{ + "error": { + "code": "FORBIDDEN", + "message": "this endpoint requires a Pro tier key" + } +} +``` + +**Causes:** +- Free-tier key attempting to access a Pro-only endpoint +- Key's account is suspended + +--- + +## Security recommendations + +- Store API keys in environment variables, never in source code +- Rotate keys regularly (issue a new key, update deployments, revoke the old key) +- Use different keys for development and production environments +- In self-hosted mode, protect `API_KEY_SALT` with the same care as a database password — changing it invalidates all existing `API_KEY_HASHES` diff --git a/docs/site/api-reference/events-get.mdx b/docs/site/api-reference/events-get.mdx new file mode 100644 index 0000000..0c82a01 --- /dev/null +++ b/docs/site/api-reference/events-get.mdx @@ -0,0 +1,115 @@ +--- +title: "GET /v1/events/:id" +description: "Retrieve a single Soroban event by its UUID." +--- + +## Endpoint + +``` +GET /v1/events/:id +``` + +Retrieve a specific indexed Soroban event by its UUID. + +--- + +## Authentication + +Requires `X-API-Key` header. See [Authentication](/api-reference/authentication). + +--- + +## Path parameters + +| Parameter | Type | Required | Description | +|-----------|----------|----------|------------------------------------| +| `id` | `string` | ✅ | UUID v4 of the event to retrieve. | + +The `id` must be a valid UUID v4 (e.g. `550e8400-e29b-41d4-a716-446655440000`). Malformed UUIDs return `400 INVALID_ARGUMENT`. + +--- + +## Example request + +```bash +curl "https://api.trident.telocel.com/v1/events/550e8400-e29b-41d4-a716-446655440000" \ + -H "X-API-Key: $TRIDENT_API_KEY" +``` + +--- + +## Response + +### 200 OK + +```json +{ + "event": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "ledgerSequence": 12345678, + "ledgerTimestamp": "2025-01-15T10:30:00Z", + "transactionHash": "3389e9f0f1a65f19935ef8b398905b88e74e9b0bb72a13dea36dc0cfd4ab2bd1", + "eventIndex": 0, + "eventType": "contract", + "topics": [ + "AAAADQAAAAh0cmFuc2Zlcg==", + "AAAAE..." + ], + "data": null, + "createdAt": "2025-01-15T10:30:01Z" + } +} +``` + +--- + +## Error responses + +| Status | Code | When | +|--------|--------------------|------------------------------------------------| +| `400` | `INVALID_ARGUMENT` | `id` is not a valid UUID v4. | +| `401` | `UNAUTHORIZED` | Missing or invalid `X-API-Key`. | +| `404` | `NOT_FOUND` | No event with this UUID exists in the index. | +| `429` | `RATE_LIMITED` | Rate limit exceeded. | +| `503` | `UNAVAILABLE` | Service temporarily unavailable. | + +### 404 Not Found + +```json +{ + "error": { + "code": "NOT_FOUND", + "message": "event not found" + } +} +``` + +--- + +## SDK equivalent + +```typescript +import { TridentClient, TridentError } from "@trident-indexer/sdk"; +import type { SorobanEvent } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +try { + const event: SorobanEvent = await client.getEventById({ + id: "550e8400-e29b-41d4-a716-446655440000", + }); + console.log("Ledger:", event.ledgerSequence); + console.log("Transaction:", event.transactionHash); +} catch (err) { + if (err instanceof TridentError && err.code === "NOT_FOUND") { + console.error("Event does not exist in the index"); + } else { + throw err; + } +} +``` diff --git a/docs/site/api-reference/events-list.mdx b/docs/site/api-reference/events-list.mdx new file mode 100644 index 0000000..59a2417 --- /dev/null +++ b/docs/site/api-reference/events-list.mdx @@ -0,0 +1,151 @@ +--- +title: "GET /v1/events" +description: "List Soroban contract events with optional filtering by contract, topics, ledger range, and cursor-based pagination." +--- + +## Endpoint + +``` +GET /v1/events +``` + +Returns a paginated list of indexed Soroban contract events. All parameters are optional — omitting all returns the most recent events across all indexed contracts. + +--- + +## Authentication + +Requires `X-API-Key` header. See [Authentication](/api-reference/authentication). + +--- + +## Query parameters + +| Parameter | Type | Default | Description | +|---------------|-----------|----------|---------------------------------------------------------------------------------| +| `contract_id` | `string` | — | Filter by Soroban contract address (C… strkey, 56 chars). | +| `topic_0` | `string` | — | Filter by the first event topic (XDR base64-encoded value, e.g. the symbol `"transfer"`). | +| `topic_1` | `string` | — | Filter by the second event topic. | +| `from_ledger` | `integer` | — | Lower bound of ledger range, inclusive. Must be ≥ 0. | +| `to_ledger` | `integer` | — | Upper bound of ledger range, inclusive. Must be ≥ `from_ledger`. | +| `network` | `string` | `testnet`| Network to query. One of: `testnet`, `mainnet`. | +| `limit` | `integer` | `50` | Max events per page. Range: 1–200. | +| `after` | `string` | — | Opaque pagination cursor from a previous response. See [Pagination](/api-reference/pagination). | + +### Validation rules + +- `contract_id` must match `^C[A-Z2-7]{55}$` (Stellar strkey format). +- `from_ledger` and `to_ledger` must be non-negative integers. +- `to_ledger` must be ≥ `from_ledger` when both are provided. +- `limit` must be an integer between 1 and 200. +- `after` must be an opaque cursor from a previous response — constructed values are rejected with `400`. +- Unknown query parameters are rejected (`?limitt=5` returns `400 INVALID_ARGUMENT`). + +--- + +## Example request + +```bash +curl "https://api.trident.telocel.com/v1/events?contract_id=CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM&topic_0=transfer&limit=2&network=testnet" \ + -H "X-API-Key: $TRIDENT_API_KEY" +``` + +--- + +## Response + +### 200 OK + +```json +{ + "events": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "ledgerSequence": 12345678, + "ledgerTimestamp": "2025-01-15T10:30:00Z", + "transactionHash": "3389e9f0f1a65f19935ef8b398905b88e74e9b0bb72a13dea36dc0cfd4ab2bd1", + "eventIndex": 0, + "eventType": "contract", + "topics": [ + "AAAADQAAAAh0cmFuc2Zlcg==", + "AAAAE..." + ], + "data": null, + "createdAt": "2025-01-15T10:30:01Z" + }, + { + "id": "550e8400-e29b-41d4-a716-446655440001", + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "ledgerSequence": 12345679, + "ledgerTimestamp": "2025-01-15T10:30:05Z", + "transactionHash": "ab12cd...", + "eventIndex": 1, + "eventType": "contract", + "topics": [ + "AAAADQAAAAh0cmFuc2Zlcg==" + ], + "data": null, + "createdAt": "2025-01-15T10:30:06Z" + } + ], + "cursor": "eyJpZCI6IjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMSJ9", + "hasMore": true +} +``` + +### SorobanEvent schema + +| Field | Type | Description | +|--------------------|----------|---------------------------------------------------------------| +| `id` | `string` | UUID v4 assigned by the indexer. | +| `contractId` | `string` | Stellar contract address (C… strkey). | +| `ledgerSequence` | `number` | Ledger sequence number when the event occurred. | +| `ledgerTimestamp` | `string` | ISO-8601 timestamp of the ledger close. | +| `transactionHash` | `string` | Transaction hash (hex). | +| `eventIndex` | `number` | Zero-based index of this event within the transaction. | +| `eventType` | `string` | One of: `contract`, `system`, `diagnostic`. | +| `topics` | `string[]`| XDR-encoded topic values (base64 strings). | +| `data` | `unknown`| Decoded event data payload. | +| `createdAt` | `string` | ISO-8601 timestamp when the record was written to the index. | + +--- + +## Error responses + +| Status | Code | When | +|--------|--------------------|------------------------------------------------------------| +| `400` | `INVALID_ARGUMENT` | A query parameter failed validation. | +| `401` | `UNAUTHORIZED` | Missing or invalid `X-API-Key`. | +| `429` | `RATE_LIMITED` | Rate limit exceeded. See [Rate Limiting](/api-reference/rate-limiting). | +| `503` | `UNAVAILABLE` | Indexer or database is temporarily unavailable. | + +```json +{ + "error": { + "code": "INVALID_ARGUMENT", + "message": "limit must be an integer between 1 and 200" + } +} +``` + +--- + +## SDK equivalent + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; +import type { PaginatedEvents } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +const result: PaginatedEvents = await client.queryEvents({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + topic0: "transfer", + limit: 2, +}); +``` diff --git a/docs/site/api-reference/health.mdx b/docs/site/api-reference/health.mdx new file mode 100644 index 0000000..8c15fb7 --- /dev/null +++ b/docs/site/api-reference/health.mdx @@ -0,0 +1,99 @@ +--- +title: "GET /v1/health" +description: "Check the health of the Trident API, indexer, database, and Redis connections." +--- + +## Endpoint + +``` +GET /v1/health +``` + +Returns the current health status of the Trident stack. This endpoint does **not** require authentication and is suitable for load balancer health checks and uptime monitors. + +--- + +## Example request + +```bash +curl https://api.trident.telocel.com/v1/health +``` + +--- + +## Response + +### 200 OK — healthy or degraded + +The endpoint returns `200` for both `"ok"` and `"degraded"` states. Only a completely unavailable service returns `503`. + +```json +{ + "status": "ok", + "indexer": { + "status": "healthy", + "lastLedger": 12345678, + "lagLedgers": 0 + }, + "database": "ok", + "redis": "ok" +} +``` + +### Response schema + +| Field | Type | Description | +|--------------------------|----------|----------------------------------------------------------------| +| `status` | `string` | Overall status: `ok`, `degraded`, or `unavailable`. | +| `indexer.status` | `string` | Indexer status: `healthy`, `degraded`, or `stopped`. | +| `indexer.lastLedger` | `number` | Ledger sequence of the last event successfully indexed. | +| `indexer.lagLedgers` | `number` | Number of ledgers the indexer is behind the chain tip. | +| `database` | `string` | Database connectivity: `ok` or `error`. | +| `redis` | `string` | Redis connectivity: `ok` or `error`. | + +### Status values explained + +| `status` | Meaning | +|---------------|-------------------------------------------------------------------------------------| +| `ok` | All components are healthy. The indexer is within normal lag tolerance. | +| `degraded` | One or more non-critical components are experiencing issues. Events are still being indexed but with possible delays. | +| `unavailable` | A critical component (database or indexer) is down. Queries may fail. | + +| `indexer.status` | Meaning | +|------------------|---------------------------------------------------------------------------| +| `healthy` | Indexer is running and caught up (or within acceptable lag). | +| `degraded` | Indexer is running but lag is high — it is catching up to the chain tip. | +| `stopped` | Indexer process is not running. | + +### 503 Service Unavailable + +Returned when the API itself cannot reach critical dependencies: + +```json +{ + "status": "unavailable", + "indexer": { + "status": "stopped", + "lastLedger": 12345600, + "lagLedgers": 78 + }, + "database": "error", + "redis": "ok" +} +``` + +--- + +## Using for load balancers + +Configure your load balancer to poll `GET /v1/health` and expect a `2xx` response. The endpoint responds quickly (no database query) and adds negligible load. + +```nginx +# nginx upstream health check example +upstream trident_api { + server api:3000; + check interval=5000 rise=2 fall=3 timeout=1000 type=http; + check_http_send "GET /v1/health HTTP/1.0\r\n\r\n"; + check_http_expect_alive http_2xx; +} +``` diff --git a/docs/site/api-reference/pagination.mdx b/docs/site/api-reference/pagination.mdx new file mode 100644 index 0000000..8130be7 --- /dev/null +++ b/docs/site/api-reference/pagination.mdx @@ -0,0 +1,131 @@ +--- +title: "Pagination" +description: "How cursor-based pagination works in Trident — the opaque cursor, the after parameter, hasMore, and iterating all pages." +--- + +All Trident list endpoints use **cursor-based pagination**. Each response includes an opaque `cursor` string that you pass as `after` to fetch the next page. + +--- + +## How it works + +A typical paginated response looks like: + +```json +{ + "events": [ /* up to `limit` events */ ], + "cursor": "eyJpZCI6IjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMCJ9", + "hasMore": true +} +``` + +| Field | Type | Description | +|-----------|-----------------|--------------------------------------------------------------| +| `events` | `SorobanEvent[]`| Events on this page, ordered by ledger sequence ascending. | +| `cursor` | `string \| null`| Pass this as `after` to get the next page. `null` = no more pages. | +| `hasMore` | `boolean` | `true` if more pages exist; `false` when you've reached the end. | + +--- + +## The opaque cursor + + +The cursor is **opaque**. Do not parse, decode, construct, or modify it. Its internal structure may change between API versions. Only pass back cursors you received from a previous response. + + +Passing a cursor you did not receive from the API will result in a `400 INVALID_ARGUMENT` error. + +--- + +## Fetching the next page + +Pass the `cursor` from the previous response as the `after` query parameter: + +```bash +# Page 1 +curl "https://api.trident.telocel.com/v1/events?limit=50" \ + -H "X-API-Key: $TRIDENT_API_KEY" + +# Page 2 — use the cursor from the page 1 response +curl "https://api.trident.telocel.com/v1/events?limit=50&after=eyJpZCI6..." \ + -H "X-API-Key: $TRIDENT_API_KEY" +``` + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; +import type { PaginatedEvents, SorobanEvent } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +// Fetch two pages manually +const page1: PaginatedEvents = await client.queryEvents({ limit: 50 }); + +if (page1.hasMore && page1.cursor) { + const page2: PaginatedEvents = await client.queryEvents({ + after: page1.cursor, + limit: 50, + }); + console.log(page2.events); +} +``` + +--- + +## Draining all pages automatically + +The SDK's `iterEvents` method handles cursor management automatically: + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; +import type { SorobanEvent } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +const allEvents: SorobanEvent[] = []; + +for await (const event of client.iterEvents({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", +})) { + allEvents.push(event); +} + +console.log(`Total: ${allEvents.length} events`); +``` + +`iterEvents` stops when `hasMore` is `false`. By default it fetches at most **100 pages**. Raise the limit for large backfills: + +```typescript +for await (const event of client.iterEvents( + { contractId: "C..." }, + { maxPages: 5000 } +)) { + // handle event +} +``` + +If the page cap is hit while results remain, a `TridentError` with `code: "ITERATION_LIMIT"` is thrown. + +--- + +## Cursor stability + +Cursors are **stable**: inserting new events does not shift the position of cursors issued before those events were indexed. You can safely checkpoint a cursor, restart your process, and resume from where you left off. + +--- + +## Parameters summary + +| Parameter | Location | Type | Description | +|-----------|-------------|----------|-------------------------------------------------------| +| `limit` | query | integer | Max events per page. Range: 1–200. Default: 50. | +| `after` | query | string | Opaque cursor from a previous response. | +| `cursor` | response | string? | Pass as `after` to get the next page. Null = end. | +| `hasMore` | response | boolean | Whether another page is available. | diff --git a/docs/site/api-reference/rate-limiting.mdx b/docs/site/api-reference/rate-limiting.mdx new file mode 100644 index 0000000..adc38f3 --- /dev/null +++ b/docs/site/api-reference/rate-limiting.mdx @@ -0,0 +1,124 @@ +--- +title: "Rate Limiting" +description: "Rate limits per API key tier, the 429 response, the Retry-After header, and backoff strategies." +--- + +Trident enforces per-key rate limits to ensure fair access and system stability. Limits are applied as a sliding-window requests-per-second (RPS) budget. + +--- + +## Limits by tier + +| Tier | Requests / second | Notes | +|--------------|-------------------|--------------------------------------------| +| **Free** | 10 RPS | Default for all new accounts | +| **Pro** | 100 RPS | [Contact us](https://trident.telocel.com/pricing) to upgrade | +| **Internal** | 1 000 RPS | Reserved for Trident infrastructure | + +These defaults apply to the hosted API. In a self-hosted deployment, configure them via: + +```bash +RATE_LIMIT_FREE_RPS=10 +RATE_LIMIT_PRO_RPS=100 +RATE_LIMIT_INTERNAL_RPS=1000 +``` + +--- + +## 429 Too Many Requests + +When you exceed your tier's limit, the API returns `429` with a `Retry-After` header: + +```http +HTTP/1.1 429 Too Many Requests +Retry-After: 1 +Content-Type: application/json + +{ + "error": { + "code": "RATE_LIMITED", + "message": "rate limit exceeded — retry after 1 second" + } +} +``` + +`Retry-After` is the number of **seconds** to wait before retrying. Always respect this header — retrying immediately will continue to return `429` and consume your budget. + +--- + +## Handling 429 in your code + +### With the SDK + +The SDK does **not** automatically retry on `429`. Handle it by catching `TridentError`: + +```typescript +import { TridentClient, TridentError } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +async function queryWithBackoff(retries = 3): Promise { + for (let attempt = 0; attempt < retries; attempt++) { + try { + const result = await client.queryEvents({ limit: 50 }); + console.log(`Got ${result.events.length} events`); + return; + } catch (err) { + if (err instanceof TridentError && err.code === "RATE_LIMITED") { + const waitMs = (err.retryAfterSeconds ?? 1) * 1000; + console.warn(`Rate limited — waiting ${waitMs}ms`); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } else { + throw err; + } + } + } + throw new Error("Max retries exceeded"); +} +``` + +### With curl (exponential backoff) + +```bash +#!/usr/bin/env bash +attempt=0 +max_attempts=5 +delay=1 + +while [ $attempt -lt $max_attempts ]; do + response=$(curl -s -w "\n%{http_code}" \ + -H "X-API-Key: $TRIDENT_API_KEY" \ + "https://api.trident.telocel.com/v1/events?limit=10") + + http_code=$(echo "$response" | tail -1) + + if [ "$http_code" = "200" ]; then + echo "$response" | head -n -1 + exit 0 + elif [ "$http_code" = "429" ]; then + echo "Rate limited — waiting ${delay}s" + sleep $delay + delay=$((delay * 2)) + attempt=$((attempt + 1)) + else + echo "Error: $http_code" + exit 1 + fi +done + +echo "Max retries exceeded" +exit 1 +``` + +--- + +## Tips for staying within limits + +- Use cursor-based `iterEvents` with a delay between pages rather than firing requests in tight loops. +- Cache results client-side when the same query is repeated frequently. +- Use WebSocket subscriptions for real-time use cases — a single persistent connection does not consume REST quota. +- If you need higher limits, [upgrade to Pro](https://trident.telocel.com/pricing). diff --git a/docs/site/changelog.mdx b/docs/site/changelog.mdx new file mode 100644 index 0000000..01a61a8 --- /dev/null +++ b/docs/site/changelog.mdx @@ -0,0 +1,39 @@ +--- +title: "Changelog" +description: "Version history and release notes for Trident API, SDK, and Indexer releases." +--- + +## v0.1.0 (Phase 1 — MVP Release) + +*Released: Q1 2026* + +### Added + +- **Rust Indexer Core (`crates/indexer`)**: + - Soroban RPC ledger ingestion pipeline decoding XDR events directly. + - PostgreSQL batch storage adapter with configurable batch size (`DB_BATCH_SIZE`). + - Redis Stream publisher (`trident:events`) for low-latency event relay. + - Adaptive poll interval scaling based on chain-tip lag (`POLL_INTERVAL_FLOOR_MS` / `POLL_INTERVAL_CEILING_MS`). + - Outbound webhook lag alerting system. + +- **Rust gRPC Internal API (`crates/api`)**: + - High-performance gRPC interface serving indexed Soroban events over protobuf schemas. + - Event streaming support (`StreamEvents`). + +- **Go Public Front Office (`services/api`)**: + - REST API endpoints (`GET /v1/events`, `GET /v1/events/:id`, `GET /v1/health`). + - Opaque cursor-based pagination supporting stable result iteration. + - Rate limiting middleware with per-tier quotas (`Free`, `Pro`, `Internal`). + - Real-time WebSocket event fan-out (`GET /ws`). + +- **TypeScript SDK (`@trident-indexer/sdk`)**: + - `TridentClient` providing full TypeScript typings out-of-the-box. + - Historical query support (`queryEvents`, `getEventById`). + - Auto-paginating async iterator (`iterEvents`). + - Real-time subscription handle with automatic exponential backoff reconnection (`subscribeToContract`). + - Custom `TridentError` class categorizing error status codes. + +- **Developer Documentation Site**: + - Full documentation site built with Mintlify. + - Comprehensive configuration reference covering every `.env.example` parameter. + - Step-by-step guides for quickstart, self-hosting, Fly.io, and monitoring setup. diff --git a/docs/site/examples/quickstart.ts b/docs/site/examples/quickstart.ts new file mode 100644 index 0000000..d0dd136 --- /dev/null +++ b/docs/site/examples/quickstart.ts @@ -0,0 +1,54 @@ +import { TridentClient, TridentError } from "@trident-indexer/sdk"; +import type { PaginatedEvents, SorobanEvent, Subscription } from "@trident-indexer/sdk"; + +async function runExamples() { + const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: "tdk_live_demo12345", + network: "testnet", + }); + + // 1. queryEvents + const page1: PaginatedEvents = await client.queryEvents({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + topic0: "transfer", + limit: 10, + }); + + console.log(`Found ${page1.events.length} events`); + + // 2. getEventById + try { + const event: SorobanEvent = await client.getEventById({ + id: "550e8400-e29b-41d4-a716-446655440000", + }); + console.log("Event ledger:", event.ledgerSequence); + } catch (err) { + if (err instanceof TridentError && err.code === "NOT_FOUND") { + console.log("Event not found"); + } + } + + // 3. iterEvents + for await (const event of client.iterEvents({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + })) { + console.log("Iterated event:", event.id); + } + + // 4. subscribeToContract + const sub: Subscription = client.subscribeToContract({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + topic0: "transfer", + onEvent: (event: SorobanEvent) => { + console.log("Live event:", event.id); + }, + onError: (err: Error) => { + console.error("Subscription error:", err.message); + }, + }); + + sub.unsubscribe(); +} + +runExamples().catch(console.error); diff --git a/docs/site/graphql/playground.mdx b/docs/site/graphql/playground.mdx new file mode 100644 index 0000000..d9d1f49 --- /dev/null +++ b/docs/site/graphql/playground.mdx @@ -0,0 +1,82 @@ +--- +title: "GraphQL Playground" +description: "Explore and test the Trident GraphQL API interactively." +--- + + +**Pending #66.** The GraphQL Playground will be available once the GraphQL API ships in [issue #66](https://github.com/Telocel-Labs/Trident/issues/66). + + +## What is the Playground? + +The Trident GraphQL Playground is an interactive in-browser IDE for exploring the Trident GraphQL API. It provides: + +- **Schema explorer** — browse every type, field, and argument +- **Query editor** — write and execute queries with autocomplete +- **Variable editor** — supply typed variables for parameterised queries +- **Response viewer** — see results formatted as JSON + +--- + +## Accessing the Playground + +Once available, the playground will be at: + +``` +https://api.trident.telocel.com/playground +``` + +You will need your API key. Enter it in the **Headers** panel: + +```json +{ + "X-API-Key": "tdk_live_xxx" +} +``` + +--- + +## Example queries to try + +Once the playground is live, try these queries to get started: + +**Latest 5 events from any contract:** + +```graphql +{ + events(first: 5) { + edges { + node { + id + contractId + ledgerSequence + topics + } + } + } +} +``` + +**Events from a specific contract:** + +```graphql +{ + events( + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + first: 10 + ) { + edges { + node { + id + ledgerSequence + transactionHash + eventType + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` diff --git a/docs/site/graphql/queries.mdx b/docs/site/graphql/queries.mdx new file mode 100644 index 0000000..fe8f852 --- /dev/null +++ b/docs/site/graphql/queries.mdx @@ -0,0 +1,131 @@ +--- +title: "GraphQL Queries" +description: "Example GraphQL queries for fetching Soroban events by contract, topic, and with pagination." +--- + + +**Pending #66.** These examples are based on the planned schema and will be verified against the live API once [issue #66](https://github.com/Telocel-Labs/Trident/issues/66) is merged. + + +## Events by contract + +```graphql +query EventsByContract($contractId: String!, $first: Int, $after: String) { + events(contractId: $contractId, first: $first, after: $after) { + edges { + node { + id + ledgerSequence + ledgerTimestamp + transactionHash + eventIndex + eventType + topics + data + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` + +**Variables:** + +```json +{ + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "first": 50 +} +``` + +--- + +## Events by topic + +```graphql +query TransferEvents($contractId: String!) { + events(contractId: $contractId, topic0: "transfer", first: 20) { + edges { + node { + id + ledgerSequence + transactionHash + topics + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` + +--- + +## Single event by ID + +```graphql +query GetEvent($id: ID!) { + event(id: $id) { + id + contractId + ledgerSequence + ledgerTimestamp + transactionHash + eventType + topics + data + createdAt + } +} +``` + +**Variables:** + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +--- + +## Paginating all events + +```graphql +query PaginateEvents($contractId: String!, $first: Int!, $after: String) { + events(contractId: $contractId, first: $first, after: $after) { + edges { + node { + id + ledgerSequence + transactionHash + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` + +Iterate using `pageInfo.endCursor` as the `after` variable on subsequent requests until `pageInfo.hasNextPage` is `false`. + +--- + +## Running queries + +```bash +curl -X POST https://api.trident.telocel.com/graphql \ + -H "Content-Type: application/json" \ + -H "X-API-Key: $TRIDENT_API_KEY" \ + -d '{ + "query": "query { events(contractId: \"CAAA...AD2KM\", first: 5) { edges { node { id ledgerSequence } } pageInfo { hasNextPage } } }" + }' +``` diff --git a/docs/site/graphql/schema.mdx b/docs/site/graphql/schema.mdx new file mode 100644 index 0000000..e2a25e2 --- /dev/null +++ b/docs/site/graphql/schema.mdx @@ -0,0 +1,99 @@ +--- +title: "GraphQL Schema" +description: "Full GraphQL schema for the Trident API — auto-generated from the SDL." +--- + + +**Pending #66.** The GraphQL API is being developed in [issue #66](https://github.com/Telocel-Labs/Trident/issues/66). This page will be updated with the full schema listing once the SDL is merged. Subscribe to that issue for updates. + + +## Overview + +The Trident GraphQL API is served at: + +``` +POST https://api.trident.telocel.com/graphql +``` + +It supports the same event query and subscription capabilities as the REST API, with the added flexibility of composable queries and field-level selection. + +--- + +## Authentication + +Pass your API key in the `X-API-Key` header (same as the REST API): + +```bash +curl -X POST https://api.trident.telocel.com/graphql \ + -H "Content-Type: application/json" \ + -H "X-API-Key: $TRIDENT_API_KEY" \ + -d '{ "query": "{ __typename }" }' +``` + +--- + +## Schema (coming in #66) + +The full SDL will be listed here once the GraphQL API ships. Core types expected: + +```graphql +# Coming in #66 + +type SorobanEvent { + id: ID! + contractId: String! + ledgerSequence: Int! + ledgerTimestamp: String! + transactionHash: String! + eventIndex: Int! + eventType: EventType! + topics: [String!]! + data: JSON + createdAt: String! +} + +enum EventType { + CONTRACT + SYSTEM + DIAGNOSTIC +} + +type PageInfo { + hasNextPage: Boolean! + endCursor: String +} + +type EventConnection { + edges: [EventEdge!]! + pageInfo: PageInfo! +} + +type EventEdge { + node: SorobanEvent! + cursor: String! +} + +type Query { + events( + contractId: String + topic0: String + topic1: String + fromLedger: Int + toLedger: Int + first: Int + after: String + ): EventConnection! + + event(id: ID!): SorobanEvent +} + +type Subscription { + eventAdded(contractId: String!, topic0: String): SorobanEvent! +} +``` + +--- + +## Interactive Playground + +Once the GraphQL API ships, explore it interactively at [/playground](/graphql/playground). diff --git a/docs/site/graphql/subscriptions.mdx b/docs/site/graphql/subscriptions.mdx new file mode 100644 index 0000000..398fc16 --- /dev/null +++ b/docs/site/graphql/subscriptions.mdx @@ -0,0 +1,107 @@ +--- +title: "GraphQL Subscriptions" +description: "Subscribe to real-time Soroban events over GraphQL WebSocket subscriptions." +--- + + +**Pending #66.** GraphQL subscriptions are part of [issue #66](https://github.com/Telocel-Labs/Trident/issues/66) and will be verified once merged. + + +## Subscription endpoint + +GraphQL subscriptions use the `graphql-ws` protocol over WebSocket: + +``` +wss://api.trident.telocel.com/graphql +``` + +--- + +## Example subscription + +```graphql +subscription OnEventAdded($contractId: String!, $topic0: String) { + eventAdded(contractId: $contractId, topic0: $topic0) { + id + contractId + ledgerSequence + ledgerTimestamp + transactionHash + eventIndex + eventType + topics + data + } +} +``` + +**Variables:** + +```json +{ + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "topic0": "transfer" +} +``` + +--- + +## Using graphql-ws client (TypeScript) + +```typescript +import { createClient } from "graphql-ws"; + +const client = createClient({ + url: "wss://api.trident.telocel.com/graphql", + connectionParams: { + "X-API-Key": process.env.TRIDENT_API_KEY!, + }, +}); + +const unsubscribe = client.subscribe( + { + query: ` + subscription OnEventAdded($contractId: String!) { + eventAdded(contractId: $contractId) { + id + ledgerSequence + transactionHash + topics + } + } + `, + variables: { + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + }, + }, + { + next: (data) => { + const event = data.data?.eventAdded; + console.log("New event:", event?.id, event?.transactionHash); + }, + error: (err) => { + console.error("Subscription error:", err); + }, + complete: () => { + console.log("Subscription complete"); + }, + } +); + +// Stop the subscription +setTimeout(unsubscribe, 60_000); +``` + +Install: `npm install graphql-ws` + +--- + +## Comparison: GraphQL subscriptions vs WebSocket subscriptions + +| Feature | GraphQL subscriptions | WebSocket `/ws` | +|--------------------------------|------------------------------|-----------------------------| +| Protocol | `graphql-ws` over WebSocket | Custom JSON over WebSocket | +| Field selection | ✅ Select only needed fields | ❌ Full event always sent | +| Multiple subscriptions | ✅ Per operation variable | ✅ Multiple subscribe frames| +| SDK support | Use `graphql-ws` library | `subscribeToContract()` SDK | +| Best for | Browser apps, dashboards | Server-side, non-JS clients | diff --git a/docs/site/introduction.mdx b/docs/site/introduction.mdx new file mode 100644 index 0000000..de8d0f7 --- /dev/null +++ b/docs/site/introduction.mdx @@ -0,0 +1,88 @@ +--- +title: "Introduction" +description: "Trident is the event indexing layer Stellar's developer ecosystem needs — query every Soroban contract event ever emitted, filtered and paginated, without running your own infrastructure." +--- + +# What is Trident? + +Soroban's RPC node is intentionally thin. It does not retain events beyond a short rolling window, has no historical query capability, and offers minimal filtering. That's a reasonable protocol decision — but it forces every team building on Stellar to solve the same infrastructure problem before they can build their actual product. + +Every mature smart contract ecosystem has solved this exactly once: + +| Ecosystem | Solution | +|------------|----------------| +| Ethereum | The Graph | +| Solana | Helius / Triton| +| Cosmos | SubQuery | +| **Stellar**| **Trident** | + +Trident is a dedicated indexing layer that streams every Soroban contract event off the network, stores it persistently, and exposes it through a clean API and SDK. A developer using Trident can query every event a contract has ever emitted — filtered by topic, paginated, in real time or historically — without writing a single line of indexing infrastructure. + +--- + +## Architecture + +The system is split into two layers with a hard boundary between them. + +``` +Stellar RPC + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ Rust Indexer (crates/indexer) │ +│ • polls Soroban RPC on a short interval │ +│ • decodes XDR → normalised SorobanEvent │ +│ • writes to PostgreSQL (durable storage) │ +│ • publishes to Redis Streams (real-time fan-out)│ +└────────────────────┬────────────────────────────┘ + │ + ┌──────────┴──────────┐ + ▼ ▼ + PostgreSQL Redis Streams + (historical (real-time + queries) subscriptions) + │ │ + └──────────┬──────────┘ + ▼ +┌─────────────────────────────────────────────────┐ +│ Rust gRPC API (crates/api) │ +│ • serves internal gRPC to the Go layer │ +└────────────────────┬────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────┐ +│ Go REST/WS/GraphQL API (services/api) │ +│ • REST → GET /v1/events, /v1/events/:id │ +│ • WS → GET /ws (real-time subscriptions) │ +│ • GQL → POST /graphql │ +└────────────────────┬────────────────────────────┘ + ▼ + TypeScript SDK (@trident-indexer/sdk) +``` + +**Historical queries** read from PostgreSQL. **Real-time delivery** reads from Redis. The two paths never interfere with each other. + +The Rust core was chosen because it decodes XDR natively through the same libraries the Stellar protocol uses, has no garbage collector to introduce latency spikes, and gives the predictable performance a 24/7 indexer demands. + +--- + +## Two ways to use Trident + + + + Use the hosted testnet API — no infrastructure to run. Install the SDK, + get an API key, and make your first query in under 5 minutes. + + + Run the full stack yourself with a single `docker compose up` command. + Full control over data retention, network, and scaling. + + + +--- + +## Core concepts + +- **SorobanEvent** — a single normalised event emitted by a Soroban contract, indexed with a UUID, ledger sequence, transaction hash, topics (XDR-encoded), and data payload. +- **Cursor-based pagination** — all list endpoints return an opaque `cursor` you pass as `after` to fetch the next page. The cursor is stable: inserting new events does not change the order of existing pages. +- **WebSocket subscriptions** — connect to `GET /ws` and subscribe to a contract. The server pushes `SorobanEvent` messages as they are indexed. +- **API key authentication** — every request (except `/v1/health`) requires an `X-API-Key` header. Keys are tied to a rate-limit tier. diff --git a/docs/site/mint.json b/docs/site/mint.json new file mode 100644 index 0000000..15a44c3 --- /dev/null +++ b/docs/site/mint.json @@ -0,0 +1,152 @@ +{ + "$schema": "https://mintlify.com/schema.json", + "name": "Trident", + "logo": { + "dark": "/logo/trident-dark.svg", + "light": "/logo/trident-light.svg" + }, + "favicon": "/favicon.svg", + "colors": { + "primary": "#6366f1", + "light": "#818cf8", + "dark": "#4f46e5", + "background": { + "dark": "#0f0f1a" + }, + "anchors": { + "from": "#6366f1", + "to": "#818cf8" + } + }, + "topbarLinks": [ + { + "name": "GitHub", + "url": "https://github.com/Telocel-Labs/Trident" + } + ], + "topbarCtaButton": { + "name": "Get API Key", + "url": "https://trident.telocel.com/signup" + }, + "tabs": [ + { + "name": "API Reference", + "url": "api-reference" + }, + { + "name": "SDK Reference", + "url": "sdk" + }, + { + "name": "WebSocket", + "url": "websocket" + }, + { + "name": "GraphQL", + "url": "graphql" + } + ], + "anchors": [ + { + "name": "Changelog", + "icon": "list", + "url": "changelog" + }, + { + "name": "GitHub", + "icon": "github", + "url": "https://github.com/Telocel-Labs/Trident" + }, + { + "name": "Discussions", + "icon": "comments", + "url": "https://github.com/Telocel-Labs/Trident/discussions" + } + ], + "navigation": [ + { + "group": "Get Started", + "pages": [ + "introduction", + "quickstart-sdk", + "quickstart-self-hosted" + ] + }, + { + "group": "API Reference", + "pages": [ + "api-reference/authentication", + "api-reference/pagination", + "api-reference/rate-limiting", + "api-reference/events-list", + "api-reference/events-get", + "api-reference/health" + ] + }, + { + "group": "WebSocket", + "pages": [ + "websocket/connecting", + "websocket/subscription-format", + "websocket/event-format", + "websocket/reconnection", + "websocket/example-sdk", + "websocket/example-raw" + ] + }, + { + "group": "GraphQL", + "pages": [ + "graphql/schema", + "graphql/queries", + "graphql/subscriptions", + "graphql/playground" + ] + }, + { + "group": "SDK Reference", + "pages": [ + "sdk/client", + "sdk/query-events", + "sdk/get-event-by-id", + "sdk/subscribe", + "sdk/errors", + "sdk/transport" + ] + }, + { + "group": "Self-Hosting", + "pages": [ + "self-hosting/prerequisites", + "self-hosting/configuration", + "self-hosting/fly-io", + "self-hosting/upgrading", + "self-hosting/monitoring" + ] + }, + { + "group": "More", + "pages": [ + "changelog" + ] + } + ], + "footerSocials": { + "github": "https://github.com/Telocel-Labs/Trident" + }, + "openapi": "/api-reference/openapi.yaml", + "api": { + "baseUrl": "https://api.trident.telocel.com", + "auth": { + "method": "key", + "name": "X-API-Key" + } + }, + "feedback": { + "thumbsRating": true, + "suggestEdit": true + }, + "search": { + "prompt": "Search Trident docs…" + } +} diff --git a/docs/site/quickstart-sdk.mdx b/docs/site/quickstart-sdk.mdx new file mode 100644 index 0000000..be68f63 --- /dev/null +++ b/docs/site/quickstart-sdk.mdx @@ -0,0 +1,155 @@ +--- +title: "Quick Start — SDK" +description: "Install the TypeScript SDK, get an API key, and make your first Soroban event query in under 5 minutes." +--- + +## 1. Install the SDK + +```bash +npm install @trident-indexer/sdk +# or +yarn add @trident-indexer/sdk +# or +pnpm add @trident-indexer/sdk +``` + +The package ships pre-built CJS + ESM bundles and a `dist/index.d.ts` declaration for full autocomplete. + +--- + +## 2. Get an API key + +Sign up at [trident.telocel.com](https://trident.telocel.com/signup) to get a free API key. The free tier gives you **10 requests/second** against the hosted testnet API. + +Your key looks like: + +``` +tdk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +Keep it secret — it is sent as a plain HTTP header on every request. + +--- + +## 3. Make your first query + +The hosted testnet API is available at `https://api.trident.telocel.com`. + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, // set in your environment + network: "testnet", +}); + +// Query the last 10 events from any contract on testnet +const result = await client.queryEvents({ limit: 10 }); + +console.log(`Found ${result.events.length} events`); +for (const event of result.events) { + console.log( + ` [${event.ledgerSequence}] ${event.contractId} — topics: ${event.topics.join(", ")}` + ); +} +``` + +**Expected output** (values will vary): + +``` +Found 10 events + [12345678] CAAAA...AD2KM — topics: AAAADQAAAAh0cmFuc2Zlcg==, ... + [12345677] CAAAA...AD2KM — topics: AAAADQAAAARtaW50, ... + ... +``` + +--- + +## 4. Filter by contract and topic + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; +import type { SorobanEvent } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +// Filter to transfer events from a specific contract +const page = await client.queryEvents({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + topic0: "transfer", + limit: 50, +}); + +console.log(`Page 1: ${page.events.length} events, hasMore=${page.hasMore}`); + +// Fetch the next page +if (page.hasMore && page.cursor) { + const page2 = await client.queryEvents({ + after: page.cursor, + limit: 50, + }); + console.log(`Page 2: ${page2.events.length} events`); +} +``` + +--- + +## 5. Drain all pages with the async iterator + +For large backlogs, use `iterEvents` instead of managing cursors manually: + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; +import type { SorobanEvent } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +let count = 0; +for await (const event of client.iterEvents({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", +})) { + count++; + // process event — typed as SorobanEvent + console.log(event.id, event.transactionHash); +} +console.log(`Total events: ${count}`); +``` + +`iterEvents` fetches pages automatically until `hasMore` is false, up to a default cap of 100 pages. Raise `maxPages` for large backfills: + +```typescript +for await (const event of client.iterEvents( + { contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" }, + { maxPages: 1000 } +)) { + // ... +} +``` + +--- + +## What's next? + + + + Explore every method, parameter, and type exported by the SDK. + + + Query the API directly with curl or any HTTP client. + + + Subscribe to live events as they land on-chain. + + + Run your own Trident stack with Docker Compose. + + diff --git a/docs/site/quickstart-self-hosted.mdx b/docs/site/quickstart-self-hosted.mdx new file mode 100644 index 0000000..6042fc1 --- /dev/null +++ b/docs/site/quickstart-self-hosted.mdx @@ -0,0 +1,164 @@ +--- +title: "Quick Start — Self-Hosted" +description: "Run the full Trident stack locally with Docker Compose and make your first curl request in minutes." +--- + +## Prerequisites + +Make sure the following are installed before you begin: + +| Requirement | Minimum version | Notes | +|---------------------|-----------------|----------------------------------------------------| +| Docker | 24+ | [Install Docker](https://docs.docker.com/get-docker/) | +| Docker Compose v2 | 2.0+ | `docker compose` (not `docker-compose`) | +| Git | any | | + +No Rust, Go, or Node.js installation is required to run the stack — everything runs inside containers. + +--- + +## 1. Clone the repository + +```bash +git clone https://github.com/Telocel-Labs/Trident.git +cd Trident +``` + +--- + +## 2. Create your `.env` file + +```bash +cp .env.example .env +``` + +Open `.env` and set the required variables. For a local testnet setup, the minimum required changes are: + +```bash +# Already set correctly for local development: +DATABASE_URL=postgresql://trident:password@localhost:5432/trident +REDIS_URL=redis://localhost:6379 +STELLAR_RPC_URL=https://soroban-testnet.stellar.org +NETWORK=testnet + +# Change this before running in production: +API_KEY_SALT=change-this-to-a-random-string +``` + + +For a full list of every environment variable and what it controls, see the [Configuration Reference](/self-hosting/configuration). + + +--- + +## 3. Start the stack + +```bash +docker compose up +``` + +This starts four services: +- **postgres** — durable event storage +- **redis** — real-time event fan-out stream +- **indexer** (Rust) — polls Soroban RPC, decodes XDR, writes to Postgres and Redis +- **api** (Go) — serves REST, WebSocket, and GraphQL APIs + +Wait until you see log lines like: + +``` +indexer | INFO trident_indexer: indexer started, polling testnet +api | INFO server listening on :3000 +``` + +--- + +## 4. Verify the stack is healthy + +```bash +curl http://localhost:3000/v1/health +``` + +Expected response: + +```json +{ + "status": "ok", + "indexer": { + "status": "healthy", + "lastLedger": 12345678, + "lagLedgers": 0 + }, + "database": "ok", + "redis": "ok" +} +``` + +--- + +## 5. Query your first events + +```bash +curl "http://localhost:3000/v1/events?network=testnet&limit=10" \ + -H "X-API-Key: your-api-key" +``` + + +In self-hosted mode, API keys are managed via the `API_KEY_HASHES` environment variable. +See the [Authentication](/api-reference/authentication) page for details. + + +Expected response shape: + +```json +{ + "events": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "ledgerSequence": 12345678, + "ledgerTimestamp": "2025-01-01T00:00:00Z", + "transactionHash": "abc123...", + "eventIndex": 0, + "eventType": "contract", + "topics": ["AAAADQAAAAh0cmFuc2Zlcg=="], + "data": null, + "createdAt": "2025-01-01T00:00:01Z" + } + ], + "cursor": "eyJpZCI6IjU1MGU4...", + "hasMore": true +} +``` + +--- + +## 6. Stop the stack + +```bash +docker compose down +``` + +Data is persisted in Docker volumes. To also wipe all data: + +```bash +docker compose down -v +``` + +--- + +## What's next? + + + + Every environment variable, its type, default, and what happens if it is missing. + + + Deploy Trident to production on Fly.io. + + + Explore all API endpoints, parameters, and response schemas. + + + Prometheus metrics, alerting, and observability. + + diff --git a/docs/site/sdk/client.mdx b/docs/site/sdk/client.mdx new file mode 100644 index 0000000..ab37764 --- /dev/null +++ b/docs/site/sdk/client.mdx @@ -0,0 +1,99 @@ +--- +title: "TridentClient" +description: "Constructor options and configuration for the TridentClient TypeScript SDK." +--- + +## Installation + +```bash +npm install @trident-indexer/sdk +# or +yarn add @trident-indexer/sdk +# or +pnpm add @trident-indexer/sdk +``` + +--- + +## Constructor + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; + +const client = new TridentClient(config: TridentClientConfig); +``` + +--- + +## TridentClientConfig + +| Option | Type | Required | Description | +|-----------------|-----------------------------------------|----------|----------------------------------------------------------------------------------| +| `apiUrl` | `string` | ✅ | Base URL of the Trident REST API. No trailing slash. Example: `https://api.trident.telocel.com` | +| `apiKey` | `string` | ✅ | API key sent as the `X-API-Key` header on every request. | +| `network` | `"mainnet" \| "testnet" \| "futurenet"` | ✅ | Stellar network to query. Included in WebSocket subscription frames. | +| `webSocketImpl` | `WebSocketConstructor` | | Custom WebSocket implementation. Defaults to the global `WebSocket` (or `ws` on Node 18/20). | + +### webSocketImpl + +By default, the SDK uses: +- **Browsers and Node.js 21+**: global `WebSocket` +- **Node.js 18/20**: dynamically imports the `ws` package — install it separately: `npm install ws` + +Override with a custom implementation for testing or specific environments: + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; +import WebSocket from "ws"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", + webSocketImpl: WebSocket, +}); +``` + +--- + +## Full example + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +// Query events +const result = await client.queryEvents({ limit: 10 }); +console.log(result.events.length); + +// Get a single event +const event = await client.getEventById({ + id: "550e8400-e29b-41d4-a716-446655440000", +}); +console.log(event.transactionHash); + +// Subscribe to live events +const sub = client.subscribeToContract({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + onEvent: (e) => console.log(e.id), +}); +sub.unsubscribe(); +``` + +--- + +## Methods + +| Method | Returns | Description | +|-------------------------|----------------------------|---------------------------------------------| +| `queryEvents(params)` | `Promise` | List events with optional filtering. | +| `getEventById(params)` | `Promise` | Fetch a single event by UUID. | +| `iterEvents(params, options?)` | `AsyncIterable` | Auto-paginating event iterator. | +| `subscribeToContract(params)` | `Subscription` | Open real-time WebSocket subscription. | + +See the individual pages for each method for full parameter and return type documentation. diff --git a/docs/site/sdk/errors.mdx b/docs/site/sdk/errors.mdx new file mode 100644 index 0000000..1f69a8e --- /dev/null +++ b/docs/site/sdk/errors.mdx @@ -0,0 +1,131 @@ +--- +title: "TridentError" +description: "Error class thrown by all TridentClient methods, with error codes and handling patterns." +--- + +## Overview + +All `TridentClient` methods throw `TridentError` on failure. `TridentError` extends the standard `Error` class and adds a `code` property for programmatic error handling. + +```typescript +import { TridentError } from "@trident-indexer/sdk"; + +try { + const event = await client.getEventById({ id: "..." }); +} catch (err) { + if (err instanceof TridentError) { + console.error(err.code, err.message); + } +} +``` + +--- + +## Properties + +| Property | Type | Description | +|-----------------------|---------------------|-----------------------------------------------------| +| `code` | `TridentErrorCode` | Machine-readable error identifier. | +| `message` | `string` | Human-readable description of the error. | +| `httpStatus` | `number \| undefined` | HTTP status code, if the error came from a response.| +| `retryAfterSeconds` | `number \| undefined` | Set on `RATE_LIMITED` — seconds to wait before retrying. | + +--- + +## Error codes + +| Code | HTTP Status | When it's thrown | +|--------------------|--------------|-----------------------------------------------------------------------------| +| `NOT_FOUND` | 404 | The requested event or resource does not exist in the index. | +| `UNAUTHORIZED` | 401 | `X-API-Key` header is missing or the key value is not recognised. | +| `FORBIDDEN` | 403 | API key is valid but does not have permission for this endpoint or tier. | +| `RATE_LIMITED` | 429 | Too many requests in the sliding window. Check `retryAfterSeconds`. | +| `INVALID_ARGUMENT` | 400 | A parameter failed validation (e.g. malformed UUID, out-of-range limit). | +| `UNAVAILABLE` | 503 | The API or indexer is temporarily unavailable. | +| `ITERATION_LIMIT` | — | `iterEvents` hit its `maxPages` cap before `hasMore` became false. | +| `INTERNAL` | 5xx / network| Unexpected server error, network failure, or unrecognised response format. | + +--- + +## Handling all codes + +```typescript +import { TridentClient, TridentError } from "@trident-indexer/sdk"; +import type { TridentErrorCode } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +async function fetchEvent(id: string) { + try { + return await client.getEventById({ id }); + } catch (err) { + if (!(err instanceof TridentError)) throw err; + + const code: TridentErrorCode = err.code; + + switch (code) { + case "NOT_FOUND": + console.warn("Event not found:", id); + return null; + + case "UNAUTHORIZED": + throw new Error("Check your TRIDENT_API_KEY environment variable"); + + case "FORBIDDEN": + throw new Error("Your API key tier cannot access this resource"); + + case "RATE_LIMITED": { + const waitMs = (err.retryAfterSeconds ?? 1) * 1000; + console.warn(`Rate limited — retrying in ${waitMs}ms`); + await new Promise((r) => setTimeout(r, waitMs)); + return fetchEvent(id); // retry + } + + case "INVALID_ARGUMENT": + throw new Error(`Invalid event ID format: ${id}`); + + case "UNAVAILABLE": + throw new Error("Trident API is temporarily unavailable — try again shortly"); + + case "INTERNAL": + default: + throw new Error(`Unexpected error: ${err.message}`); + } + } +} +``` + +--- + +## Iteration limit + +`ITERATION_LIMIT` is thrown by `iterEvents` when the page cap is reached: + +```typescript +import { TridentClient, TridentError } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +try { + for await (const event of client.iterEvents( + { contractId: "CAAA...AD2KM" }, + { maxPages: 10 } // cap at 10 pages (500 events at default limit) + )) { + console.log(event.id); + } +} catch (err) { + if (err instanceof TridentError && err.code === "ITERATION_LIMIT") { + console.warn("Reached page limit — increase maxPages for full backfill"); + } else { + throw err; + } +} +``` diff --git a/docs/site/sdk/get-event-by-id.mdx b/docs/site/sdk/get-event-by-id.mdx new file mode 100644 index 0000000..08ed49b --- /dev/null +++ b/docs/site/sdk/get-event-by-id.mdx @@ -0,0 +1,119 @@ +--- +title: "getEventById()" +description: "Fetch a single Soroban event by its UUID." +--- + +## Signature + +```typescript +getEventById(params: GetEventByIdParams): Promise +``` + +--- + +## Parameters + +| Parameter | Type | Required | Description | +|-----------|----------|----------|------------------------------------| +| `id` | `string` | ✅ | UUID v4 of the event to retrieve. | + +--- + +## Returns + +`Promise` + +```typescript +interface SorobanEvent { + /** UUID v4 assigned by the indexer. */ + id: string; + /** Stellar contract address that emitted the event (C… strkey). */ + contractId: string; + /** Ledger sequence number in which the event occurred. */ + ledgerSequence: number; + /** ISO-8601 timestamp of the ledger close. */ + ledgerTimestamp: string; + /** Transaction hash (hex). */ + transactionHash: string; + /** Zero-based index of this event within the transaction. */ + eventIndex: number; + /** Whether this is a contract-emitted, system, or diagnostic event. */ + eventType: EventType; + /** Array of XDR-encoded topic values (as base64 strings). */ + topics: string[]; + /** Decoded event data payload. */ + data: unknown; + /** ISO-8601 timestamp when this record was written to the index. */ + createdAt: string; +} + +type EventType = "contract" | "system" | "diagnostic"; +``` + +--- + +## Throws + +| Error code | When | +|------------------|-------------------------------------------------------| +| `NOT_FOUND` | No event with this UUID exists in the index. | +| `UNAUTHORIZED` | Missing or invalid `X-API-Key`. | +| `RATE_LIMITED` | Rate limit exceeded. | +| `INVALID_ARGUMENT`| `id` is not a valid UUID v4. | +| `INTERNAL` | Unexpected server or network error. | + +--- + +## Examples + +### Basic usage + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; +import type { SorobanEvent } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +const event: SorobanEvent = await client.getEventById({ + id: "550e8400-e29b-41d4-a716-446655440000", +}); + +console.log("Ledger:", event.ledgerSequence); +console.log("Contract:", event.contractId); +console.log("Transaction:", event.transactionHash); +console.log("Topics:", event.topics); +``` + +### Handling 404 + +```typescript +import { TridentClient, TridentError } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +try { + const event = await client.getEventById({ + id: "550e8400-e29b-41d4-a716-446655440000", + }); + return event; +} catch (err) { + if (err instanceof TridentError) { + if (err.code === "NOT_FOUND") { + console.warn("Event not found in index"); + return null; + } + if (err.code === "UNAUTHORIZED") { + throw new Error("Invalid API key — check your TRIDENT_API_KEY"); + } + } + throw err; +} +``` diff --git a/docs/site/sdk/query-events.mdx b/docs/site/sdk/query-events.mdx new file mode 100644 index 0000000..8685276 --- /dev/null +++ b/docs/site/sdk/query-events.mdx @@ -0,0 +1,136 @@ +--- +title: "queryEvents()" +description: "Query historical Soroban events with optional filtering by contract, topic, and ledger range." +--- + +## Signature + +```typescript +queryEvents(params: QueryEventsParams): Promise +``` + +Returns a single page of events matching the given filters. Results are cursor-paginated — use `iterEvents` to drain all pages automatically. + +--- + +## Parameters + +| Parameter | Type | Required | Description | +|--------------|----------|----------|----------------------------------------------------------------------------| +| `contractId` | `string` | | Stellar contract address (C… strkey, 56 chars). | +| `topic0` | `string` | | Filter by the first event topic (e.g. `"transfer"`, `"mint"`). | +| `topic1` | `string` | | Filter by the second event topic. | +| `ledgerFrom` | `number` | | Only return events from this ledger sequence onward (inclusive). | +| `ledgerTo` | `number` | | Only return events up to and including this ledger sequence. | +| `after` | `string` | | Opaque pagination cursor from a previous `queryEvents` response. | +| `limit` | `number` | | Max events per page. Range: 1–200. Server default: 50. | + +--- + +## Returns + +`Promise` + +```typescript +interface PaginatedEvents { + /** Events matching the query on this page. */ + events: SorobanEvent[]; + /** Pass as `after` on the next call to fetch the next page. Null when no more pages exist. */ + cursor: string | null; + /** True when more pages are available. */ + hasMore: boolean; +} +``` + +--- + +## Throws + +`TridentError` on: +- Network failure (`code: "INTERNAL"`) +- Invalid API key (`code: "UNAUTHORIZED"`) +- Rate limit exceeded (`code: "RATE_LIMITED"`) +- Invalid parameters (`code: "INVALID_ARGUMENT"`) + +--- + +## Examples + +### Basic query + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; +import type { PaginatedEvents } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +const result: PaginatedEvents = await client.queryEvents({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + topic0: "transfer", + limit: 50, +}); + +console.log(`Found ${result.events.length} events`); +``` + +### Filter by ledger range + +```typescript +const result = await client.queryEvents({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + ledgerFrom: 12_000_000, + ledgerTo: 12_001_000, + limit: 200, +}); +``` + +### Manual pagination + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; +import type { PaginatedEvents, SorobanEvent } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +let cursor: string | undefined; +const allEvents: SorobanEvent[] = []; + +do { + const page: PaginatedEvents = await client.queryEvents({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + after: cursor, + limit: 200, + }); + allEvents.push(...page.events); + cursor = page.cursor ?? undefined; +} while (cursor); + +console.log(`Total: ${allEvents.length}`); +``` + +### Auto-paginate with iterEvents + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +for await (const event of client.iterEvents({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + topic0: "transfer", +})) { + console.log(event.id, event.transactionHash); +} +``` diff --git a/docs/site/sdk/subscribe.mdx b/docs/site/sdk/subscribe.mdx new file mode 100644 index 0000000..9ce8619 --- /dev/null +++ b/docs/site/sdk/subscribe.mdx @@ -0,0 +1,147 @@ +--- +title: "subscribeToContract()" +description: "Open a real-time WebSocket subscription to events from a Soroban contract." +--- + +## Signature + +```typescript +subscribeToContract(params: SubscribeToContractParams): Subscription +``` + +Opens a WebSocket connection and begins delivering events to your callback. The connection auto-reconnects with exponential backoff (500ms → 30s max) on unexpected disconnects. + +--- + +## Parameters + +| Parameter | Type | Required | Description | +|--------------|---------------------------------|----------|-----------------------------------------------------------------------------| +| `contractId` | `string` | ✅ | Stellar contract address (C… strkey) to subscribe to. | +| `topic0` | `string` | | Optional topic filter. Only events where the first topic matches are delivered. | +| `onEvent` | `(event: SorobanEvent) => void` | ✅ | Called for every incoming event matching the subscription filter. | +| `onError` | `(error: Error) => void` | | Called on WebSocket errors. The subscription continues to auto-reconnect. | + +--- + +## Returns + +`Subscription` + +```typescript +interface Subscription { + /** Close the WebSocket connection and cancel any pending reconnect. */ + unsubscribe: () => void; +} +``` + +Calling `unsubscribe()` is idempotent — calling it multiple times is safe. + +--- + +## Examples + +### Basic subscription + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; +import type { SorobanEvent, Subscription } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +const sub: Subscription = client.subscribeToContract({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + topic0: "transfer", + onEvent: (event: SorobanEvent) => { + console.log(`[${event.ledgerSequence}] ${event.transactionHash}`); + console.log(" Topics:", event.topics); + }, + onError: (err: Error) => { + console.warn("WebSocket error (auto-retrying):", err.message); + }, +}); + +// Close after 5 minutes +setTimeout(() => sub.unsubscribe(), 5 * 60_000); +``` + +### React hook example + +```typescript +import { useEffect, useState } from "react"; +import { TridentClient } from "@trident-indexer/sdk"; +import type { SorobanEvent } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.NEXT_PUBLIC_TRIDENT_API_KEY!, + network: "testnet", +}); + +function useContractEvents(contractId: string): SorobanEvent[] { + const [events, setEvents] = useState([]); + + useEffect(() => { + const sub = client.subscribeToContract({ + contractId, + onEvent: (event) => { + setEvents((prev) => [event, ...prev].slice(0, 100)); // keep last 100 + }, + }); + return () => sub.unsubscribe(); // cleanup on unmount + }, [contractId]); + + return events; +} +``` + +### Node.js server — graceful shutdown + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +const sub = client.subscribeToContract({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + onEvent: (event) => { + // Process event — write to database, emit webhook, etc. + console.log("Event:", event.id); + }, + onError: (err) => console.error("WS error:", err.message), +}); + +// Graceful shutdown +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + sub.unsubscribe(); + process.exit(0); + }); +} +``` + +--- + +## Reconnection behaviour + +The SDK reconnects automatically after unexpected disconnects: + +| Attempt | Wait | +|---------|--------| +| 1st | 500ms | +| 2nd | 1s | +| 3rd | 2s | +| 4th | 4s | +| 5th+ | 30s | + +All subscriptions are re-registered automatically after each reconnect. Calling `unsubscribe()` cancels any pending reconnect immediately. + +See [Reconnection Behavior](/websocket/reconnection) for full details. diff --git a/docs/site/sdk/transport.mdx b/docs/site/sdk/transport.mdx new file mode 100644 index 0000000..1ca3f67 --- /dev/null +++ b/docs/site/sdk/transport.mdx @@ -0,0 +1,63 @@ +--- +title: "Transport Selection" +description: "How to choose between the REST and GraphQL transports when using the Trident SDK." +--- + +## Overview + +The Trident SDK currently uses the **REST API** transport by default for all `queryEvents`, `getEventById`, and `iterEvents` calls. GraphQL transport support will be added once the GraphQL API ships in [issue #66](https://github.com/Telocel-Labs/Trident/issues/66). + +WebSocket subscriptions (`subscribeToContract`) always use the WebSocket transport — they are not affected by the REST/GraphQL selection. + +--- + +## REST transport (default) + +All SDK methods use the REST transport out of the box. No configuration is required. + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", + // transport defaults to REST +}); + +const result = await client.queryEvents({ limit: 10 }); +``` + +--- + +## GraphQL transport (coming in #66) + +Once the GraphQL API ships, you will be able to select GraphQL as the transport: + +```typescript +// Future API — not yet available +import { TridentClient } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", + transport: "graphql", // coming in #66 +}); +``` + +--- + +## Which transport should I use? + +| Consideration | REST | GraphQL | +|-----------------------|-------------------------------|----------------------------------| +| Current availability | ✅ Available now | 🔜 Coming in #66 | +| Field selection | Fixed response shape | Select only the fields you need | +| Batch queries | Separate requests | Single document, multiple ops | +| Pagination | `cursor` / `after` params | Relay-style `Connection` types | +| Subscriptions | Not applicable — both use WS | Not applicable | +| Tooling | curl, any HTTP client | GraphQL clients, Playground | +| Best for | Simple scripts, CLIs, servers | Complex frontends, dashboards | + +For most use cases, the REST transport is simpler and sufficient. Choose GraphQL when your application needs field-level selection or you are already using a GraphQL client library. diff --git a/docs/site/self-hosting/configuration.mdx b/docs/site/self-hosting/configuration.mdx new file mode 100644 index 0000000..76b84c9 --- /dev/null +++ b/docs/site/self-hosting/configuration.mdx @@ -0,0 +1,211 @@ +--- +title: "Configuration Reference" +description: "Every environment variable accepted by the Trident stack, with type, default, required status, and what happens if it is missing." +--- + +All configuration is done through environment variables. Copy `.env.example` to `.env` and fill in real values: + +```bash +cp .env.example .env +``` + +Variables marked **REQUIRED** must be set before the service starts. Variables marked **OPTIONAL** have defaults and can be omitted. Variables marked **REQUIRED-IF** must be set when a specific feature is enabled. + +--- + +## Shared (all services) + +| Variable | Required | Type | Default | Services | Description | +|----------------|----------|----------|---------|----------------------|-------------| +| `DATABASE_URL` | REQUIRED | string | — | indexer, grpc-api, go-api | PostgreSQL connection string. In production, use the PgBouncer pooler URL, not the direct Postgres port. | +| `REDIS_URL` | REQUIRED | string | — | indexer, go-api | Redis connection string. The indexer publishes events here; the Go API consumes them for WebSocket fan-out. | + +--- + +## Database connection pool sizing + +| Variable | Required | Type | Default | Service | Description | +|-----------------------|----------|---------|---------|------------|-------------| +| `INDEXER_DB_POOL_SIZE`| OPTIONAL | integer | `3` | indexer | Pool size for the indexer. Single writer, low concurrency — keep small. | +| `GRPC_API_DB_POOL_SIZE`| OPTIONAL | integer | `10` | grpc-api | Pool size for the Rust gRPC API. Read-heavy, moderate concurrency. | +| `GO_API_DB_POOL_SIZE` | OPTIONAL | integer | `5` | go-api | Per-replica pool. With 3 replicas = 15 total connections against PgBouncer. | +| `PGBOUNCER_ADMIN_URL` | OPTIONAL | string | — | go-api | PgBouncer admin console connection. Used by `GET /v1/admin/db`. | + +--- + +## Rust Indexer (`crates/indexer`) + +### Core + +| Variable | Required | Type | Default | Description | +|---------------------|----------|---------|------------|-------------| +| `STELLAR_RPC_URL` | REQUIRED | string | — | Soroban RPC endpoint. Testnet: `https://soroban-testnet.stellar.org`. | +| `NETWORK` | OPTIONAL | string | `testnet` | Network identifier. One of: `mainnet`, `testnet`, `futurenet`. | +| `RUST_LOG` | OPTIONAL | string | `info` | Log verbosity. One of: `error`, `warn`, `info`, `debug`, `trace`. | +| `HEALTH_PORT` | OPTIONAL | integer | `8080` | Port serving `/healthz` and `/readyz`. | +| `METRICS_PORT` | OPTIONAL | integer | `9090` | Port serving Prometheus `/metrics`. Not authenticated — firewall it. | +| `INDEX_DIAGNOSTIC` | OPTIONAL | boolean | `false` | Store Soroban diagnostic events. Leave `false` in production — they are high-volume. | +| `NETWORK_PASSPHRASE`| OPTIONAL | string | — | Stellar network passphrase. Inferred automatically for standard networks. Required for custom networks. | +| `TRACKED_SAC_ASSETS`| OPTIONAL | string | — | Assets (e.g. `USDC:GA...`) to derive Stellar Asset Contract IDs for and track. | + +### Polling and batching + +| Variable | Required | Type | Default | Min | Max | Description | +|----------------------------|----------|---------|---------|-------|--------|-------------| +| `POLL_INTERVAL_MS` | OPTIONAL | integer | `1000` | `100` | `60000`| How often (ms) the indexer polls between full pagination runs. | +| `POLL_INTERVAL_FLOOR_MS` | OPTIONAL | integer | `250` | `50` | `60000`| Shortest poll interval — used when lag ≥ `LAG_HIGH_WATERMARK`. | +| `POLL_INTERVAL_CEILING_MS` | OPTIONAL | integer | `5000` | `100` | `600000`| Longest poll interval — used when the indexer is fully caught up. Must be > `POLL_INTERVAL_FLOOR_MS`. | +| `LAG_HIGH_WATERMARK` | OPTIONAL | integer | `100` | `1` | `100000000`| Chain-tip lag (ledgers) at or above which the floor interval applies. | +| `POLL_HYSTERESIS_LEDGERS` | OPTIONAL | integer | `10` | `0` | `1000000`| Hysteresis deadband (ledgers) to prevent oscillation around a threshold. | +| `MAX_EVENTS_PER_POLL` | OPTIONAL | integer | `200` | `1` | `10000`| Max events fetched from the RPC per `getEvents` request. Reduce to lower peak memory; increase to speed up backfill. | +| `DB_BATCH_SIZE` | OPTIONAL | integer | `1000` | `1` | `10000`| Max rows per batched `INSERT` on page commit. | +| `INDEX_TOPIC_FILTERS` | OPTIONAL | string | — | — | — | Comma-separated topic patterns for server-side RPC filtering (e.g. `transfer/*/*,mint/*/*`). Only applied when the contract allowlist is non-empty. | +| `REDIS_STREAM_MAXLEN` | OPTIONAL | integer | `10000` | — | — | Max events kept in the Redis stream before trimming. | + +### Database timeouts + +| Variable | Required | Type | Default | Description | +|---------------------------------------|----------|---------|---------|-------------| +| `DB_STATEMENT_TIMEOUT_MS` | OPTIONAL | integer | `30000` | Postgres per-statement timeout (ms). | +| `DB_IDLE_IN_TRANSACTION_TIMEOUT_MS` | OPTIONAL | integer | `10000` | Postgres idle-in-transaction timeout (ms). | + +### RPC transport and failover + +| Variable | Required | Type | Default | Description | +|-----------------------------|----------|---------|---------|-------------| +| `STELLAR_RPC_URLS` | OPTIONAL | string | — | Prioritised comma-separated RPC endpoints. Overrides `STELLAR_RPC_URL`. | +| `RPC_CONNECT_TIMEOUT_MS` | OPTIONAL | integer | `5000` | TCP connect timeout for RPC calls. | +| `RPC_REQUEST_TIMEOUT_MS` | OPTIONAL | integer | `30000` | Overall RPC request timeout. Must be ≥ `RPC_CONNECT_TIMEOUT_MS`. | +| `RPC_POOL_IDLE_TIMEOUT_MS` | OPTIONAL | integer | `90000` | How long an idle pooled connection is kept. | +| `RPC_POOL_MAX_IDLE_PER_HOST`| OPTIONAL | integer | `8` | Idle keep-alive connections retained per RPC host. | +| `RPC_TCP_KEEPALIVE_MS` | OPTIONAL | integer | `60000` | TCP keep-alive probe interval. | +| `RPC_FAILOVER_THRESHOLD` | OPTIONAL | integer | `3` | Consecutive failures before an endpoint is parked. | +| `RPC_ENDPOINT_COOLDOWN_MS` | OPTIONAL | integer | `30000` | How long a parked endpoint waits before retrying. | + +### Outbox relay + +| Variable | Required | Type | Default | Description | +|--------------------------------|----------|---------|---------|-------------| +| `OUTBOX_POLL_INTERVAL_MS` | OPTIONAL | integer | `100` | How often the outbox relay polls for new events. | +| `OUTBOX_BATCH_SIZE` | OPTIONAL | integer | `500` | Max events relayed per batch. | +| `OUTBOX_BACKLOG_ALERT_THRESHOLD`| OPTIONAL | integer | `10000`| Outbox depth that triggers an alert. | + +### Alerting + +| Variable | Required | Type | Default | Description | +|------------------------|----------|---------|---------|-------------| +| `ALERT_WEBHOOK_URL` | OPTIONAL | string | — | Outbound webhook URL for lag/recovery alerts. Leave empty to disable. | +| `ALERT_LAG_THRESHOLD` | OPTIONAL | integer | `200` | Lag (ledgers) above which an alert fires. | +| `ALERT_COOLDOWN_MINUTES`| OPTIONAL | integer | `30` | Minimum minutes between repeated lag alerts. | + +### Token metadata and diagnostics + +| Variable | Required | Type | Default | Description | +|---------------------------------------|----------|---------|---------|-------------| +| `TOKEN_METADATA_REFRESH_INTERVAL_SECS`| OPTIONAL | integer | `86400`| How often (seconds) cached token metadata is refreshed. | +| `TOKIO_CONSOLE_ENABLED` | OPTIONAL | boolean | `false`| Enables tokio-console diagnostics. Only works when built with the `tokio-console` Cargo feature. | + +--- + +## Rust gRPC API (`crates/api`) + +| Variable | Required | Type | Default | Description | +|-----------------------|----------|---------|------------|-------------| +| `GRPC_ADDR` | REQUIRED | string | `0.0.0.0:50051` | Address the gRPC server binds to. | +| `STREAM_CHANNEL_BUFFER`| OPTIONAL | integer | `128` | In-flight event buffer per `StreamEvents` subscriber. Raise for bursty clients; lower to cap per-subscriber memory. | + +--- + +## Go REST API (`services/api`) + +### Core + +| Variable | Required | Type | Default | Description | +|------------------------|---------------|---------|--------------|-------------| +| `API_GRPC_ADDR` | REQUIRED | string | — | Address of the upstream Rust gRPC API. | +| `PORT` | OPTIONAL | integer | `3000` | Port the Go HTTP server listens on. | +| `API_KEY_SALT` | OPTIONAL | string | — | HMAC-SHA256 salt for hashing API keys. **Change before production.** Generate: `openssl rand -hex 32`. | +| `API_KEY_HASHES` | OPTIONAL | string | — | Comma-separated HMAC-SHA256 hashes of accepted API keys. | +| `ADMIN_API_KEY` | OPTIONAL | string | — | Shared secret for `X-Admin-Key` header on `/v1/admin/*`. Leave empty to disable admin endpoints. | +| `ALLOWED_ORIGINS` | REQUIRED (prod)| string | — | Comma-separated CORS allow-list. Dev allows any origin when unset — never leave unset in production. | +| `REQUEST_TIMEOUT_MS` | OPTIONAL | integer | `30000` | Per-request timeout (ms). Does not apply to `/ws` or `/v1/events/stream`. | +| `MAX_WS_CONNECTIONS` | OPTIONAL | integer | `1000` | Max concurrent WebSocket connections before new ones are rejected. | +| `REDIS_STREAM_KEY` | OPTIONAL | string | `trident:events` | Redis stream key for event pub/sub. Must match between indexer and Go API. | + +### Rate limiting + +| Variable | Required | Type | Default | Description | +|---------------------------|----------|---------|---------|-------------| +| `RATE_LIMIT_FREE_RPS` | OPTIONAL | integer | `10` | Requests/sec limit for Free tier API keys. | +| `RATE_LIMIT_PRO_RPS` | OPTIONAL | integer | `100` | Requests/sec limit for Pro tier API keys. | +| `RATE_LIMIT_INTERNAL_RPS` | OPTIONAL | integer | `1000` | Requests/sec limit for Internal tier keys. | + +### Retention (data pruning) + +| Variable | Required | Type | Default | Description | +|--------------------------------|----------|---------|---------|-------------| +| `RETENTION_AUDIT_LOG_DAYS` | OPTIONAL | integer | `90` | Days to retain audit log rows. | +| `RETENTION_PARSE_ERRORS_DAYS` | OPTIONAL | integer | `30` | Days to retain parse error rows. | +| `RETENTION_WEBHOOK_DELIVERIES_DAYS`| OPTIONAL | integer | `30` | Days to retain webhook delivery rows. | +| `RETENTION_SOROBAN_EVENTS_DAYS`| OPTIONAL | integer | `0` | Days to retain Soroban event rows. `0` = no pruning (unlimited retention). | + +### Webhook delivery + +| Variable | Required | Type | Default | Description | +|--------------------------|----------|---------|----------------------|-------------| +| `WEBHOOK_CONSUMER_GROUP` | OPTIONAL | string | `trident-webhooks` | Redis Stream consumer group for the webhook delivery worker. | +| `WEBHOOK_CONSUMER_NAME` | OPTIONAL | string | `webhook-worker` | Redis Stream consumer name. | + +### Request body limits and abuse protection + +| Variable | Required | Type | Default | Description | +|---------------------------|----------|---------|-------------|-------------| +| `MAX_IN_FLIGHT_REQUESTS` | OPTIONAL | integer | `500` | Global concurrency cap. Requests beyond this many in-flight get `503`. | +| `PER_IP_RATE_LIMIT_RPS` | OPTIONAL | integer | `20` | Per-IP sliding-window request limit, applied before auth. | +| `PER_IP_RATE_LIMIT_WINDOW_MS`| OPTIONAL | integer | `1000` | Window (ms) for the per-IP rate limit. | +| `TRUSTED_PROXY_ENABLED` | OPTIONAL | boolean | `false` | Set `true` only when behind the nginx reverse proxy. Resolves client IP from `X-Forwarded-For`. Do NOT enable if untrusted clients can reach the API directly. | + +### Profiling + +| Variable | Required | Type | Default | Description | +|---------------|----------|---------|-----------------|-------------| +| `PPROF_ENABLED`| OPTIONAL | boolean | `false` | Enable internal pprof profiling server. Never expose publicly. | +| `PPROF_ADDR` | OPTIONAL | string | `127.0.0.1:6060`| Bind address for pprof. Loopback only. | + +### Internal status auth + +| Variable | Required | Type | Default | Description | +|--------------------|------------------|--------|---------|-------------| +| `INTERNAL_API_KEY` | REQUIRED-IF using `/internal/status` | string | — | Shared secret for `X-Internal-Key` header. Compared with constant-time comparison. Leaving unset causes every request to be rejected (fails closed). Generate: `openssl rand -hex 32`. | + +--- + +## mTLS (internal gRPC hop) + +| Variable | Required | Type | Default | Description | +|-------------------------|----------|---------|---------|-------------| +| `GRPC_MTLS_ENABLED` | OPTIONAL | boolean | `false` | Enables mutual TLS on the Go API ↔ Rust gRPC API hop. When false, that hop is plaintext within the cluster network. | +| `GRPC_MTLS_CA_CERT` | REQUIRED-IF `GRPC_MTLS_ENABLED=true` | string | — | Path to CA bundle for verifying the peer certificate. | +| `GRPC_MTLS_SERVER_CERT` | REQUIRED-IF `GRPC_MTLS_ENABLED=true` | string | — | Path to gRPC server TLS certificate. | +| `GRPC_MTLS_SERVER_KEY` | REQUIRED-IF `GRPC_MTLS_ENABLED=true` | string | — | Path to gRPC server TLS private key. | +| `GRPC_MTLS_CLIENT_CERT` | REQUIRED-IF `GRPC_MTLS_ENABLED=true` | string | — | Path to Go API client TLS certificate. | +| `GRPC_MTLS_CLIENT_KEY` | REQUIRED-IF `GRPC_MTLS_ENABLED=true` | string | — | Path to Go API client TLS private key. | + +--- + +## Docker Compose + +| Variable | Required | Type | Default | Description | +|--------------------|----------|--------|------------|-------------| +| `POSTGRES_USER` | OPTIONAL | string | `trident` | PostgreSQL superuser username (used on first container create). | +| `POSTGRES_PASSWORD`| OPTIONAL | string | `password` | PostgreSQL superuser password. **Change in production.** | +| `POSTGRES_DB` | OPTIONAL | string | `trident` | PostgreSQL database name. | + +--- + +## OpenTelemetry distributed tracing + +| Variable | Required | Type | Default | Description | +|-----------------------------|----------|--------|---------|-------------| +| `OTEL_EXPORTER_OTLP_ENDPOINT`| OPTIONAL | string | — | OTLP gRPC endpoint for the trace collector (Jaeger, Grafana Tempo, etc.). Leave empty to disable tracing with zero overhead. Local dev: `http://localhost:4317`. | +| `OTEL_SAMPLING_RATIO` | OPTIONAL | float | `0.1` | Fraction of traces to sample. `1.0` = 100% (useful in development). Reduce in production if trace volume is too high. | diff --git a/docs/site/self-hosting/fly-io.mdx b/docs/site/self-hosting/fly-io.mdx new file mode 100644 index 0000000..673e5f8 --- /dev/null +++ b/docs/site/self-hosting/fly-io.mdx @@ -0,0 +1,146 @@ +--- +title: "Deploying on Fly.io" +description: "Step-by-step walkthrough for deploying the Trident stack to Fly.io using TOML configuration files." +--- + +## Overview + +Trident provides pre-configured Fly.io deployment configurations in the `fly/` directory: + +- `fly/indexer.toml` — Rust indexer service +- `fly/grpc-api.toml` — Rust gRPC API service +- `fly/api.toml` — Go REST / WebSocket API front office + +This guide walks through deploying all three components along with managed Postgres and Redis instances on Fly.io. + +--- + +## Prerequisites + +1. Install the Fly CLI (`flyctl`): + ```bash + curl -L https://fly.io/install.sh | sh + ``` +2. Log in to Fly.io: + ```bash + fly auth login + ``` + +--- + +## Step 1: Provision Postgres & Redis + +### Fly Postgres + +Create a Fly Postgres cluster (or PgBouncer pooler): + +```bash +fly postgres create --name trident-db --region iad --initial-cluster-size 2 --vm-size shared-cpu-2x --volume-size 50 +``` + +Attach Postgres to obtain the connection string: +```bash +fly postgres attach trident-db --app trident-api +``` + +### Fly Redis / Upstash + +Create an Upstash Redis instance on Fly: + +```bash +fly redis create --name trident-redis --region iad +``` + +Note down the `REDIS_URL` connection string provided upon creation. + +--- + +## Step 2: Deploy Rust Indexer + +The indexer reads blocks from Soroban RPC and writes to Postgres & Redis. + +1. Navigate to the indexer configuration context or specify config location: + ```bash + fly secrets set \ + DATABASE_URL="postgres://trident:password@trident-db.internal:5432/trident" \ + REDIS_URL="redis://default:password@trident-redis.upstash.io:6379" \ + STELLAR_RPC_URL="https://soroban-testnet.stellar.org" \ + NETWORK="testnet" \ + --config fly/indexer.toml + ``` + +2. Deploy the indexer: + ```bash + fly deploy --config fly/indexer.toml + ``` + +--- + +## Step 3: Deploy Rust gRPC API + +The internal gRPC API bridges Postgres storage to the Go front office. + +1. Set required secrets: + ```bash + fly secrets set \ + DATABASE_URL="postgres://trident:password@trident-db.internal:5432/trident" \ + --config fly/grpc-api.toml + ``` + +2. Deploy gRPC API: + ```bash + fly deploy --config fly/grpc-api.toml + ``` + +--- + +## Step 4: Deploy Go REST/WS API + +The Go API exposes the public HTTP and WebSocket endpoints to client SDKs. + +1. Set secrets and environment configurations: + ```bash + fly secrets set \ + DATABASE_URL="postgres://trident:password@trident-db.internal:5432/trident" \ + REDIS_URL="redis://default:password@trident-redis.upstash.io:6379" \ + API_GRPC_ADDR="trident-grpc-api.internal:50051" \ + API_KEY_SALT="$(openssl rand -hex 32)" \ + ALLOWED_ORIGINS="https://yourdomain.com" \ + --config fly/api.toml + ``` + +2. Deploy Go API: + ```bash + fly deploy --config fly/api.toml + ``` + +--- + +## Step 5: Verify Deployment + +Check status and logs across apps: + +```bash +fly status --config fly/api.toml +fly logs --config fly/api.toml +``` + +Test health check endpoint: + +```bash +curl https://trident-api.fly.dev/v1/health +``` + +Expected output: +```json +{ + "status": "ok", + "indexer": { + "status": "healthy", + "lastLedger": 12345678, + "lagLedgers": 0 + }, + "database": "ok", + "redis": "ok" +} +``` diff --git a/docs/site/self-hosting/monitoring.mdx b/docs/site/self-hosting/monitoring.mdx new file mode 100644 index 0000000..6eb4150 --- /dev/null +++ b/docs/site/self-hosting/monitoring.mdx @@ -0,0 +1,84 @@ +--- +title: "Monitoring & Observability" +description: "Prometheus metrics, key performance indicators, lag tracking, and operational alert setup for Trident." +--- + +## Overview + +Trident emits comprehensive Prometheus metrics to monitor indexer throughput, RPC lag, database connection pools, and API endpoint latencies. + +--- + +## Prometheus Metrics Summary + +The indexer exposes metrics on `:9090/metrics` (configurable via `METRICS_PORT`). + +### Core Indexer Metrics + +| Metric Name | Type | Description | +|-------------|------|-------------| +| `trident_indexer_latest_ledger` | Gauge | Most recent ledger sequence ingested by Trident. | +| `trident_indexer_chain_tip_ledger` | Gauge | Latest ledger reported by the Stellar Soroban RPC node. | +| `trident_indexer_ledger_lag` | Gauge | Difference between chain tip and current indexer ledger. | +| `trident_indexer_events_processed_total` | Counter | Cumulative count of Soroban events decoded and indexed. | +| `trident_indexer_rpc_duration_seconds` | Histogram | Latency of `getEvents` RPC calls made to Soroban nodes. | + +### Database & Pool Metrics + +| Metric Name | Type | Description | +|-------------|------|-------------| +| `trident_db_pool_active_connections` | Gauge | Active connections currently leased from PgBouncer/Postgres. | +| `trident_db_pool_idle_connections` | Gauge | Idle connections available in pool. | +| `trident_db_batch_insert_duration_seconds` | Histogram | Time spent writing event batches to PostgreSQL. | + +--- + +## Alerting Guidelines + +Configure key Prometheus rules for early detection of degradation: + +### 1. High Ledger Lag + +Alert if the indexer lags behind the network tip by more than 200 ledgers (~15–20 minutes): + +```yaml +groups: + - name: trident_alerts + rules: + - alert: TridentHighLedgerLag + expr: trident_indexer_ledger_lag > 200 + for: 5m + labels: + severity: critical + annotations: + summary: "Trident indexer is lagging behind chain tip" + description: "Indexer lag is currently {{ $value }} ledgers." +``` + +### 2. High RPC Error Rate + +Alert when RPC failover thresholds are triggered or request failure rates spike: + +```yaml + - alert: TridentRPCFailures + expr: rate(trident_indexer_rpc_errors_total[5m]) > 0.1 + for: 2m + labels: + severity: warning + annotations: + summary: "Elevated RPC error rate detected" +``` + +--- + +## Webhook Alerts + +The indexer supports outbound alerting via `ALERT_WEBHOOK_URL`. Configure in `.env`: + +```bash +ALERT_WEBHOOK_URL=https://hooks.slack.com/services/XXX/YYY/ZZZ +ALERT_LAG_THRESHOLD=200 +ALERT_COOLDOWN_MINUTES=30 +``` + +When triggered, Trident sends JSON payloads containing lag details directly to your webhook endpoint. diff --git a/docs/site/self-hosting/prerequisites.mdx b/docs/site/self-hosting/prerequisites.mdx new file mode 100644 index 0000000..fbe0372 --- /dev/null +++ b/docs/site/self-hosting/prerequisites.mdx @@ -0,0 +1,58 @@ +--- +title: "Prerequisites" +description: "What you need before running a self-hosted Trident deployment." +--- + +## Required software + +| Software | Minimum version | How to install | +|----------------|-----------------|-------------------------------------------------| +| Docker | 24+ | [docs.docker.com/get-docker](https://docs.docker.com/get-docker/) | +| Docker Compose | v2 (`docker compose` command) | Included with Docker Desktop; on Linux: [docs.docker.com/compose/install](https://docs.docker.com/compose/install/) | +| Git | any | [git-scm.com](https://git-scm.com/) | + + +No Rust, Go, or Node.js installation is required to **run** the stack. Everything runs inside Docker containers. You only need the native toolchains if you want to build from source for development. + + +--- + +## Infrastructure requirements + +### Minimum (testnet, single instance) + +| Resource | Minimum | Recommended | +|----------|---------------|--------------| +| CPU | 2 vCPU | 4 vCPU | +| RAM | 2 GB | 4 GB | +| Disk | 20 GB SSD | 100 GB SSD | +| Network | 10 Mbit/s | 100 Mbit/s | + +### Postgres and Redis + +The Docker Compose stack includes Postgres and Redis containers. For production: +- Use a managed Postgres service (e.g. Fly Postgres, RDS, Supabase) for easier backups and scaling +- Use a managed Redis service (e.g. Upstash, Fly Redis) for persistence guarantees +- If running your own containers, ensure volumes are backed up + +--- + +## Network access + +The indexer needs outbound HTTPS access to the Soroban RPC endpoint: +- **Testnet**: `https://soroban-testnet.stellar.org` +- **Mainnet**: your chosen RPC provider + +The REST API listens on port `3000` by default. Expose it through a reverse proxy (nginx, Caddy, Fly.io) — never expose it directly to the internet without TLS. + +--- + +## For production deployments + +Additional requirements for a production deployment: + +- **TLS certificate** — `fullchain.pem` and `privkey.pem` (the included nginx overlay expects these) +- **DNS A record** pointing to your server's public IP +- **Secrets management** — API key salt, admin key, and database passwords should be stored in a secrets manager (Fly secrets, AWS Secrets Manager, etc.), not in `.env` files committed to source control + +See the [Fly.io Deployment](/self-hosting/fly-io) guide for a complete production walkthrough. diff --git a/docs/site/self-hosting/upgrading.mdx b/docs/site/self-hosting/upgrading.mdx new file mode 100644 index 0000000..d8a3fd8 --- /dev/null +++ b/docs/site/self-hosting/upgrading.mdx @@ -0,0 +1,76 @@ +--- +title: "Upgrading & Migrations" +description: "Migration strategies, schema updates, and version compatibility guidelines for self-hosted Trident deployments." +--- + +## Overview + +When upgrading a self-hosted Trident deployment, care must be taken to ensure zero downtime for API consumers and data integrity across database migrations. + +--- + +## Upgrade Workflow + +Always perform upgrades in the following sequential order: + +``` +1. Run Database Migrations ──► 2. Deploy Indexer & gRPC API ──► 3. Deploy Go API / SDK updates +``` + +--- + +## Step 1: Database Migrations + +Trident uses goose / SQL-based migrations located in `database/migrations/`. + +Before deploying new binary releases, apply pending schema migrations against PostgreSQL: + +```bash +# Using Docker +docker run --rm -v $(pwd)/database/migrations:/migrations \ + --net=host \ + migrate/migrate \ + -path=/migrations \ + -database "$DATABASE_URL" up +``` + +Alternatively, `make dev` or standard Docker Compose setup automatically executes migrations prior to starting services. + +--- + +## Step 2: Service Updates + +Update container images for the core services: + +```bash +docker compose pull indexer grpc-api api +docker compose up -d --no-deps indexer grpc-api +``` + +Verify indexer stability via metrics/health endpoint: +```bash +curl http://localhost:8080/healthz +``` + +--- + +## Step 3: API & Front Office Updates + +Once the indexer and gRPC backend are running cleanly, update the Go API: + +```bash +docker compose up -d --no-deps api +``` + +Verify public API health: +```bash +curl http://localhost:3000/v1/health +``` + +--- + +## Breaking Changes & Rollbacks + +- Database migrations are designed to be backwards compatible for at least one minor release. +- If a rollback is required, roll back service versions before reverting database migrations. +- Refer to the [Changelog](/changelog) for specific release upgrade notes. diff --git a/docs/site/tsconfig.json b/docs/site/tsconfig.json new file mode 100644 index 0000000..d413ce0 --- /dev/null +++ b/docs/site/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["examples/**/*.ts"] +} diff --git a/docs/site/websocket/connecting.mdx b/docs/site/websocket/connecting.mdx new file mode 100644 index 0000000..49e8d58 --- /dev/null +++ b/docs/site/websocket/connecting.mdx @@ -0,0 +1,71 @@ +--- +title: "Connecting" +description: "How to open a WebSocket connection to the Trident real-time event stream." +--- + +## Endpoint + +``` +GET /ws +``` + +Establishes a WebSocket connection for real-time Soroban event delivery. Send a subscription frame after the connection opens to start receiving events. + +--- + +## Connection URL + +``` +wss://api.trident.telocel.com/ws +``` + +For self-hosted deployments: + +``` +ws://localhost:3000/ws +``` + +--- + +## Authentication + +Pass your API key as a query parameter on the initial HTTP upgrade request: + +``` +wss://api.trident.telocel.com/ws?api_key=tdk_live_xxx +``` + + +Passing the key as a query parameter is unavoidable for WebSocket connections (browsers do not allow custom headers during the WebSocket handshake). Do not log or expose WebSocket connection URLs, as they contain the key. + + +### Alternative: send key in first message + +Some environments cannot set query parameters on WebSocket URLs. In that case, omit the query parameter and send your API key as the first message after connecting: + +```json +{ + "type": "auth", + "apiKey": "tdk_live_xxx" +} +``` + +The server will respond with `{"type": "auth_ok"}` before processing any subscription frames. + +--- + +## Connection limits + +| Tier | Max concurrent WebSocket connections | +|----------|--------------------------------------| +| Free | 5 per API key | +| Pro | 100 per API key | +| Internal | 10 000 per API key | + +In self-hosted mode, the global cap is set by `MAX_WS_CONNECTIONS` (default: 1000). + +--- + +## Next steps + +Once connected, send a [Subscription frame](/websocket/subscription-format) to begin receiving events. diff --git a/docs/site/websocket/event-format.mdx b/docs/site/websocket/event-format.mdx new file mode 100644 index 0000000..38d870b --- /dev/null +++ b/docs/site/websocket/event-format.mdx @@ -0,0 +1,72 @@ +--- +title: "Event Message Format" +description: "The JSON shape of event messages delivered over a Trident WebSocket connection." +--- + +## Event message + +When an event matching your subscription arrives, the server sends a message of type `"event"`: + +```json +{ + "type": "event", + "event": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "ledgerSequence": 12345678, + "ledgerTimestamp": "2025-01-15T10:30:00Z", + "transactionHash": "3389e9f0f1a65f19935ef8b398905b88e74e9b0bb72a13dea36dc0cfd4ab2bd1", + "eventIndex": 0, + "eventType": "contract", + "topics": [ + "AAAADQAAAAh0cmFuc2Zlcg==", + "AAAAE..." + ], + "data": null, + "createdAt": "2025-01-15T10:30:01Z" + } +} +``` + +### Message schema + +| Field | Type | Description | +|----------------|----------|--------------------------------------------------------------------------| +| `type` | `string` | Always `"event"` for event messages. | +| `event` | `object` | The `SorobanEvent` record — same schema as the REST API responses. | + +### SorobanEvent fields + +| Field | Type | Description | +|-------------------|------------|----------------------------------------------------------------| +| `id` | `string` | UUID v4 assigned by the indexer. | +| `contractId` | `string` | Stellar contract address (C… strkey). | +| `ledgerSequence` | `number` | Ledger sequence number when the event occurred. | +| `ledgerTimestamp` | `string` | ISO-8601 timestamp of the ledger close. | +| `transactionHash` | `string` | Transaction hash (hex). | +| `eventIndex` | `number` | Zero-based index of this event within the transaction. | +| `eventType` | `string` | One of: `contract`, `system`, `diagnostic`. | +| `topics` | `string[]` | XDR-encoded topic values (base64 strings). | +| `data` | `unknown` | Decoded event data payload. | +| `createdAt` | `string` | ISO-8601 timestamp when the record was written to the index. | + +--- + +## Ordering guarantee + +Events are delivered in **ledger sequence order**. Within a ledger, events are delivered in `eventIndex` order. Events are never delivered out of order, and no event is delivered twice on the same connection. + +--- + +## Other message types + +In addition to `"event"` messages, the server may send: + +| `type` | When it's sent | +|---------------|----------------------------------------------------------| +| `subscribed` | Acknowledges a successful `subscribe` frame. | +| `unsubscribed`| Acknowledges a successful `unsubscribe` frame. | +| `error` | Sent when a frame the client sent was invalid. | +| `ping` | Sent every 30 seconds to keep the connection alive. | + +Respond to `"ping"` messages with a `"pong"` frame (the SDK handles this automatically). diff --git a/docs/site/websocket/example-raw.mdx b/docs/site/websocket/example-raw.mdx new file mode 100644 index 0000000..61a0271 --- /dev/null +++ b/docs/site/websocket/example-raw.mdx @@ -0,0 +1,214 @@ +--- +title: "WebSocket — Raw Client Example" +description: "Connect to the Trident WebSocket API from non-TypeScript clients using the raw protocol." +--- + +This page shows how to use the Trident WebSocket API without the TypeScript SDK — useful for Python, Go, Rust, or browser-native clients. + +--- + +## Protocol overview + +1. Open a WebSocket connection to `wss://api.trident.telocel.com/ws?api_key=` +2. Send a `subscribe` frame (JSON) +3. Receive `event` messages (JSON) for every matching contract event +4. Respond to `ping` messages with `pong` to keep the connection alive +5. Send `unsubscribe` or close the connection when done + +All messages are UTF-8 encoded JSON text frames. + +--- + +## Browser (JavaScript) + +```javascript +const apiKey = "tdk_live_xxx"; +const contractId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + +const ws = new WebSocket( + `wss://api.trident.telocel.com/ws?api_key=${apiKey}` +); + +ws.addEventListener("open", () => { + console.log("Connected"); + ws.send(JSON.stringify({ + type: "subscribe", + contractId, + topic0: "transfer", + })); +}); + +ws.addEventListener("message", (ev) => { + const msg = JSON.parse(ev.data); + if (msg.type === "event") { + console.log("New event:", msg.event.id, msg.event.transactionHash); + } else if (msg.type === "ping") { + ws.send(JSON.stringify({ type: "pong" })); + } else if (msg.type === "error") { + console.error("Server error:", msg.code, msg.message); + } +}); + +ws.addEventListener("close", (ev) => { + console.warn(`Disconnected: code=${ev.code}, reason=${ev.reason}`); + // Implement your own reconnect logic here +}); +``` + +--- + +## Python + +```python +import asyncio +import json +import websockets + +API_KEY = "tdk_live_xxx" +CONTRACT_ID = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + +async def main() -> None: + url = f"wss://api.trident.telocel.com/ws?api_key={API_KEY}" + async with websockets.connect(url) as ws: + await ws.send(json.dumps({ + "type": "subscribe", + "contractId": CONTRACT_ID, + "topic0": "transfer", + })) + + async for raw in ws: + msg = json.loads(raw) + match msg["type"]: + case "event": + evt = msg["event"] + print(f"[{evt['ledgerSequence']}] {evt['id']} — {evt['transactionHash']}") + case "ping": + await ws.send(json.dumps({"type": "pong"})) + case "subscribed": + print("Subscription confirmed:", msg["contractId"]) + case "error": + print("Error:", msg["code"], msg["message"]) + +asyncio.run(main()) +``` + +Install the dependency: `pip install websockets` + +--- + +## Go + +```go +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/url" + + "github.com/gorilla/websocket" +) + +type SubscribeFrame struct { + Type string `json:"type"` + ContractID string `json:"contractId"` + Topic0 string `json:"topic0,omitempty"` +} + +type IncomingMessage struct { + Type string `json:"type"` + Event json.RawMessage `json:"event,omitempty"` + Code string `json:"code,omitempty"` +} + +func main() { + apiKey := "tdk_live_xxx" + u := url.URL{ + Scheme: "wss", + Host: "api.trident.telocel.com", + Path: "/ws", + RawQuery: "api_key=" + apiKey, + } + + conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + log.Fatal("dial:", err) + } + defer conn.Close() + + sub := SubscribeFrame{ + Type: "subscribe", + ContractID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + Topic0: "transfer", + } + if err := conn.WriteJSON(sub); err != nil { + log.Fatal("subscribe:", err) + } + + for { + _, msg, err := conn.ReadMessage() + if err != nil { + log.Println("disconnect:", err) + break + } + + var incoming IncomingMessage + json.Unmarshal(msg, &incoming) + + switch incoming.Type { + case "event": + fmt.Printf("Event: %s\n", incoming.Event) + case "ping": + conn.WriteJSON(map[string]string{"type": "pong"}) + case "error": + fmt.Printf("Error: %s\n", incoming.Code) + } + } +} +``` + +Install the dependency: `go get github.com/gorilla/websocket` + +--- + +## Rust + +```rust +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +#[tokio::main] +async fn main() { + let api_key = "tdk_live_xxx"; + let url = format!( + "wss://api.trident.telocel.com/ws?api_key={api_key}" + ); + + let (mut ws, _) = connect_async(&url).await.expect("Failed to connect"); + + // Subscribe + let sub = json!({ + "type": "subscribe", + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "topic0": "transfer" + }); + ws.send(Message::Text(sub.to_string())).await.unwrap(); + + while let Some(msg) = ws.next().await { + if let Ok(Message::Text(text)) = msg { + let parsed: Value = serde_json::from_str(&text).unwrap(); + match parsed["type"].as_str() { + Some("event") => println!("Event: {}", parsed["event"]["id"]), + Some("ping") => { + ws.send(Message::Text(r#"{"type":"pong"}"#.to_string())) + .await + .unwrap(); + } + _ => {} + } + } + } +} +``` diff --git a/docs/site/websocket/example-sdk.mdx b/docs/site/websocket/example-sdk.mdx new file mode 100644 index 0000000..bc78b87 --- /dev/null +++ b/docs/site/websocket/example-sdk.mdx @@ -0,0 +1,105 @@ +--- +title: "WebSocket — SDK Example" +description: "Subscribe to real-time Soroban contract events using the TypeScript SDK." +--- + +## subscribeToContract() + +The SDK's `subscribeToContract` method opens a WebSocket connection, registers your subscription, and delivers events to your callback — with automatic reconnection built in. + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; +import type { SorobanEvent, Subscription } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +// Subscribe to all transfer events from a contract +const sub: Subscription = client.subscribeToContract({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + topic0: "transfer", // optional — filter by first topic + onEvent: (event: SorobanEvent) => { + console.log(`[ledger ${event.ledgerSequence}] ${event.contractId}`); + console.log(` tx: ${event.transactionHash}`); + console.log(` topics: ${event.topics.join(", ")}`); + }, + onError: (err: Error) => { + // Called on errors — the SDK continues to reconnect + console.error("WebSocket error:", err.message); + }, +}); + +// The subscription stays open until you call unsubscribe() +// To close it after 60 seconds: +setTimeout(() => { + sub.unsubscribe(); + console.log("Unsubscribed."); +}, 60_000); +``` + +--- + +## Listening to multiple contracts + +Call `subscribeToContract` multiple times — each call creates an independent WebSocket connection with its own reconnect lifecycle: + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; +import type { SorobanEvent } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +function handleEvent(label: string) { + return (event: SorobanEvent) => { + console.log(`[${label}] Event: ${event.id}`); + }; +} + +const sub1 = client.subscribeToContract({ + contractId: "CAAA...token1", + topic0: "transfer", + onEvent: handleEvent("token1"), +}); + +const sub2 = client.subscribeToContract({ + contractId: "CBBB...token2", + topic0: "mint", + onEvent: handleEvent("token2"), +}); + +// Tear down both subscriptions +process.on("SIGINT", () => { + sub1.unsubscribe(); + sub2.unsubscribe(); + process.exit(0); +}); +``` + +--- + +## TypeScript types + +```typescript +interface SubscribeToContractParams { + /** Stellar contract address to subscribe to (C… strkey). */ + contractId: string; + /** Optional topic filter — only events where the first topic matches are delivered. */ + topic0?: string; + /** Called for every incoming event matching the filter. */ + onEvent: (event: SorobanEvent) => void; + /** Called on WebSocket errors. The subscription continues to auto-reconnect. */ + onError?: (error: Error) => void; +} + +interface Subscription { + /** Close the WebSocket connection and cancel any pending reconnect. */ + unsubscribe: () => void; +} +``` diff --git a/docs/site/websocket/reconnection.mdx b/docs/site/websocket/reconnection.mdx new file mode 100644 index 0000000..6420ddb --- /dev/null +++ b/docs/site/websocket/reconnection.mdx @@ -0,0 +1,107 @@ +--- +title: "Reconnection Behavior" +description: "How the SDK handles WebSocket disconnects and how to implement reconnection in custom clients." +--- + +## SDK reconnection (automatic) + +The SDK reconnects automatically after unexpected disconnects using **exponential backoff**: + +| Attempt | Wait before reconnect | +|---------|-----------------------| +| 1st | 500 ms | +| 2nd | 1 000 ms | +| 3rd | 2 000 ms | +| 4th | 4 000 ms | +| 5th+ | 30 000 ms (max) | + +The backoff resets to 500 ms after a successful reconnect that stays connected for more than 60 seconds. + +**Subscriptions are automatically re-registered** after every reconnect — you do not need to re-send subscription frames. + +Calling `subscription.unsubscribe()` cancels any pending reconnect immediately. + +### onError callback + +The `onError` callback is called when a reconnect attempt fails, but the subscription continues to retry: + +```typescript +import { TridentClient } from "@trident-indexer/sdk"; + +const client = new TridentClient({ + apiUrl: "https://api.trident.telocel.com", + apiKey: process.env.TRIDENT_API_KEY!, + network: "testnet", +}); + +const sub = client.subscribeToContract({ + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + onEvent: (event) => { + console.log("Event:", event.id); + }, + onError: (err) => { + // Called on errors — the SDK will still attempt to reconnect + console.warn("WebSocket error (will retry):", err.message); + }, +}); + +// Later: +sub.unsubscribe(); // cancels all pending reconnects +``` + +--- + +## Custom client reconnection + +If you are building a non-TypeScript client using raw WebSockets, implement your own reconnection loop: + +```python +# Python example using websockets library +import asyncio +import json +import websockets + +async def subscribe_with_reconnect(api_key: str, contract_id: str): + backoff = 0.5 + max_backoff = 30.0 + + while True: + try: + url = f"wss://api.trident.telocel.com/ws?api_key={api_key}" + async with websockets.connect(url) as ws: + # Re-register subscription after every (re)connect + await ws.send(json.dumps({ + "type": "subscribe", + "contractId": contract_id + })) + + backoff = 0.5 # reset on successful connect + async for message in ws: + data = json.loads(message) + if data["type"] == "event": + print("Event:", data["event"]["id"]) + except websockets.ConnectionClosed as e: + print(f"Disconnected ({e.code}), reconnecting in {backoff}s") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, max_backoff) + except Exception as e: + print(f"Error: {e}, reconnecting in {backoff}s") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, max_backoff) + +asyncio.run(subscribe_with_reconnect("tdk_live_xxx", "CAAA...")) +``` + +--- + +## What causes disconnects? + +| Cause | SDK behavior | +|------------------------------|---------------------------------------------| +| Network interruption | Auto-reconnects with backoff | +| Server restart | Auto-reconnects with backoff | +| Idle timeout (no traffic) | Server sends `ping` every 30s to prevent this | +| `unsubscribe()` called | Connection closed, no reconnect | +| Invalid subscription frame | Server sends `error` message, stays connected | +| Rate limit exceeded | Server closes with code `4029`, SDK reconnects after backoff | +| Invalid API key | Server closes with code `4001`, SDK calls `onError` and stops retrying | diff --git a/docs/site/websocket/subscription-format.mdx b/docs/site/websocket/subscription-format.mdx new file mode 100644 index 0000000..688beba --- /dev/null +++ b/docs/site/websocket/subscription-format.mdx @@ -0,0 +1,76 @@ +--- +title: "Subscription Format" +description: "The JSON payload format for subscribing to contract events over a Trident WebSocket connection." +--- + +## Subscribing to a contract + +After the WebSocket connection is open, send a subscription frame to begin receiving events: + +```json +{ + "type": "subscribe", + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "topic0": "transfer" +} +``` + +### Subscription frame schema + +| Field | Type | Required | Description | +|--------------|----------|----------|---------------------------------------------------------------------| +| `type` | `string` | ✅ | Must be `"subscribe"`. | +| `contractId` | `string` | ✅ | Stellar contract address (C… strkey, 56 chars) to subscribe to. | +| `topic0` | `string` | | Optional topic filter applied server-side. Only events where the first topic matches this value are delivered. | + +### Server acknowledgement + +On a valid subscription, the server responds immediately: + +```json +{ + "type": "subscribed", + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "topic0": "transfer" +} +``` + +On an invalid subscription (e.g. malformed `contractId`): + +```json +{ + "type": "error", + "code": "INVALID_ARGUMENT", + "message": "contractId must be a valid Soroban contract strkey" +} +``` + +--- + +## Multiple subscriptions + +A single WebSocket connection supports **multiple simultaneous subscriptions**. Send additional subscription frames for each contract: + +```json +{ "type": "subscribe", "contractId": "CAAA...contract1" } +{ "type": "subscribe", "contractId": "CBBB...contract2", "topic0": "mint" } +``` + +Each subscription is independent — events from all subscribed contracts arrive on the same connection, tagged with their `contractId`. + +--- + +## Unsubscribing + +To stop receiving events from a specific contract without closing the connection: + +```json +{ + "type": "unsubscribe", + "contractId": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" +} +``` + +The server responds with `{"type": "unsubscribed", "contractId": "..."}`. + +To stop all subscriptions, close the WebSocket connection.