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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 163 additions & 67 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
@@ -1,120 +1,216 @@
# @stellar-explain/sdk

TypeScript SDK for the [Stellar Explain](../../README.md) API.
TypeScript SDK for the [Stellar Explain](../../README.md) API — translate raw Stellar blockchain transactions and accounts into plain-language explanations.

> **New to Stellar?** You don't need to know anything about the Stellar blockchain to use this SDK. Just pass in a transaction hash or account address and get back a human-readable summary.

## Installation

```bash
# npm
npm install @stellar-explain/sdk

# yarn
yarn add @stellar-explain/sdk

# pnpm
pnpm add @stellar-explain/sdk
```

## Quick start

```typescript
```ts
import { StellarExplainClient } from '@stellar-explain/sdk';

const client = new StellarExplainClient({ baseUrl: 'https://your-api-host' });
const client = new StellarExplainClient();

// Explain a transaction
const tx = await client.explainTransaction(
'abc123...',
'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
);
console.log(tx.summary);
// → "Sent 10.00 XLM from GABC… to GXYZ…"

// Explain an account
const account = await client.explainAccount('GABC...');
const account = await client.explainAccount('GABC1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567');
console.log(account.summary);
// → "Account holding 250.00 XLM with 2 trustlines"
```

## Request cancellation (AbortSignal)
## Configuration

Pass an `AbortSignal` to cancel a request early — for example when a React
component unmounts:
All options are optional. The client works out of the box pointing at the hosted API.

```typescript
const controller = new AbortController();
```ts
const client = new StellarExplainClient({
baseUrl: 'https://api.stellarexplain.io', // default
timeoutMs: 30_000, // default: 30 s
});
```

// Cancel when component unmounts
useEffect(() => () => controller.abort(), []);
| Option | Type | Default | Description |
|---|---|---|---|
| `baseUrl` | `string` | `"https://api.stellarexplain.io"` | Base URL of the Stellar Explain API |
| `timeoutMs` | `number` | `30000` | Per-request timeout in milliseconds |
| `fetchImpl` | `FetchImpl` | `globalThis.fetch` | Custom WHATWG-compatible fetch (see Node 16 section) |
| `logger` | `SdkLogger` | silent | Structured logger (`console`, `pino`, `winston`, …) |
| `plugins` | `SdkPlugin[]` | `[]` | Request/response interceptor hooks |
| `cache` | `CacheAdapter` | `MemoryCache` (5 min TTL) | Custom cache backend |

const tx = await client.explainTransaction(hash, { signal: controller.signal });
```
## API reference

When the signal fires the returned Promise rejects with
`TimeoutError('Request cancelled')`.
### `client.explainTransaction(hash)`

In-flight requests that are shared between multiple callers (via the built-in
deduplication map) remain in-flight even after a consumer cancels — only the
cancelling caller's Promise is rejected.
Fetches a plain-language explanation for a Stellar transaction.

## Node 16 support
```ts
const tx = await client.explainTransaction(
'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
);

console.log(tx.hash); // '64-char hex hash'
console.log(tx.summary); // 'Sent 10.00 XLM from … to …'
console.log(tx.status); // 'success' | 'failed'
console.log(tx.ledger); // 1234567
console.log(tx.created_at); // '2024-01-15T10:30:00Z'
console.log(tx.fee_charged); // '100' (stroops)
console.log(tx.memo); // 'Hello' | null
console.log(tx.payments); // PaymentExplanation[]
console.log(tx.skipped_operations); // number
```

Node 18+ ships a native `fetch` implementation and requires no extra setup.
Returns `Promise<TransactionExplanation>`.

On **Node 16**, `globalThis.fetch` is not available. Pass a fetch implementation
via the `fetchImpl` option.
- Throws `InvalidInputError` immediately if `hash` is not a valid 64-character hex string — no network call is made.
- Throws `NotFoundError` if the transaction does not exist (not retried).
- Throws `TimeoutError` if the request exceeds `timeoutMs`.

### Option A — pass undici's `fetch` directly (recommended for most cases)
### `client.explainAccount(address)`

```bash
npm install undici
Fetches a plain-language explanation for a Stellar account.

```ts
const account = await client.explainAccount(
'GABC1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567',
);

console.log(account.account_id); // 'G…'
console.log(account.summary); // 'Account holding …'
console.log(account.last_modified_ledger); // 1234567
console.log(account.subentry_count); // 3
console.log(account.home_domain); // 'example.com' | undefined
console.log(account.balances); // AssetBalance[]
console.log(account.signers); // Signer[]
```

```typescript
import { fetch } from 'undici';
import { StellarExplainClient } from '@stellar-explain/sdk';
Returns `Promise<AccountExplanation>`.

const client = new StellarExplainClient({
baseUrl: 'https://your-api-host',
fetchImpl: fetch,
});
- Throws `InvalidInputError` immediately if `address` is not a valid Stellar G-address — no network call is made.
- Throws `NotFoundError` if the account does not exist (not retried).

### `client.health()`

Check the health of the Stellar Explain API.

```ts
const { status, horizon_reachable, version } = await client.health();
// status: 'ok' | 'degraded' | 'down'
```

### Option B — `createUndiciFetch` with custom pool options
Returns `Promise<HealthResponse>`.

Use this when you need fine-grained control over undici's connection pool
(e.g. max concurrent connections, pipelining, TLS options).
### `client.clearCache()`

```typescript
import { createUndiciFetch } from '@stellar-explain/sdk/adapters/undiciFetch';
import { StellarExplainClient } from '@stellar-explain/sdk';
Evict all cached responses so the next calls hit the network.

const client = new StellarExplainClient({
baseUrl: 'https://your-api-host',
fetchImpl: createUndiciFetch({ connections: 10 }),
});
```ts
client.clearCache();
```

`createUndiciFetch` emits a `console.warn` if `globalThis.fetch` is already
present in the runtime, because in that case the adapter is unnecessary.
## Error handling

All SDK errors extend `StellarExplainError`. Use `instanceof` to narrow the type:

```ts
import {
StellarExplainClient,
NotFoundError,
RateLimitError,
TimeoutError,
InvalidInputError,
NetworkError,
UpstreamError,
} from '@stellar-explain/sdk';

const client = new StellarExplainClient();

try {
const tx = await client.explainTransaction(hash);
} catch (err) {
if (err instanceof InvalidInputError) {
// Bad hash format — fix the input, no point retrying
console.error('Invalid input:', err.message);
} else if (err instanceof NotFoundError) {
// Transaction doesn't exist on the network
console.error('Not found');
} else if (err instanceof RateLimitError) {
// Too many requests — respect the Retry-After header
const waitMs = (err.retryAfter ?? 60) * 1000;
await new Promise((r) => setTimeout(r, waitMs));
} else if (err instanceof TimeoutError) {
// Request took longer than timeoutMs
console.error('Timed out');
} else if (err instanceof NetworkError) {
// DNS failure, connection refused, etc.
console.error('Network error:', err.message);
} else if (err instanceof UpstreamError) {
// Unexpected response from the server
console.error('Upstream error', err.statusCode, err.message);
}
}
```

## API reference
| Error class | When thrown | `statusCode` |
|---|---|---|
| `InvalidInputError` | Malformed hash or address | — |
| `NotFoundError` | API returned 404 | `404` |
| `RateLimitError` | API returned 429; check `.retryAfter` | `429` |
| `TimeoutError` | Request exceeded `timeoutMs` | — |
| `NetworkError` | DNS/TCP-level failure | — |
| `UpstreamError` | Non-JSON body or unexpected HTTP error | varies |

### `new StellarExplainClient(options)`
## Self-hosted setup

| Option | Type | Default | Description |
|---|---|---|---|
| `baseUrl` | `string` | — | API base URL (no trailing slash) |
| `timeoutMs` | `number` | `30000` | Per-request timeout in milliseconds |
| `fetchImpl` | `FetchImpl` | `globalThis.fetch` | Custom fetch implementation |
Point the client at your own backend by setting `baseUrl`:

### `client.explainTransaction(hash, options?)`
```ts
const client = new StellarExplainClient({
baseUrl: 'http://localhost:3000',
});
```

Returns `Promise<TransactionExplanation>`.
All other options work the same way against a local backend.

| Option | Type | Description |
|---|---|---|
| `signal` | `AbortSignal` | Cancel the request |
## Node 16 support

### `client.explainAccount(address, options?)`
Node 18+ ships native `fetch` — no extra setup needed.

Returns `Promise<AccountExplanation>`.
On **Node 16**, pass a fetch implementation via `fetchImpl`:

| Option | Type | Description |
|---|---|---|
| `signal` | `AbortSignal` | Cancel the request |
```bash
npm install undici
```

```ts
import { fetch } from 'undici';
import { StellarExplainClient } from '@stellar-explain/sdk';

const client = new StellarExplainClient({
baseUrl: 'https://api.stellarexplain.io',
fetchImpl: fetch,
});
```

### Errors
## Contributing

| Class | When |
|---|---|
| `TimeoutError` | Request timed out (`'Request timed out'`) or cancelled (`'Request cancelled'`) |
| `ApiRequestError` | API returned a non-2xx response; includes `.code` from the error body |
See the monorepo [CONTRIBUTING.md](../../CONTRIBUTING.md) for development setup, testing, and pull request guidelines.
47 changes: 32 additions & 15 deletions packages/sdk/src/client/StellarExplainClient.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { MemoryCache } from "../cache/MemoryCache.js";
import { PluginRegistry } from "../plugins/index.js";
import {
InvalidInputError,
NetworkError,
NotFoundError,
RateLimitError,
StellarExplainError,
TimeoutError,
UpstreamError,
} from "../errors/index.js";
import {
validateTransactionHash,
validateAccountAddress,
withRetry,
} from "../utils/index.js";
import type {
AccountExplanation,
ApiError,
Expand All @@ -20,6 +24,8 @@ import type {
TransactionExplanation,
} from "../types/index.js";

const DEFAULT_BASE_URL = "https://api.stellarexplain.io";

/** Default TTL for cached responses: 5 minutes in milliseconds. */
const DEFAULT_TTL_MS = 5 * 60 * 1000;

Expand Down Expand Up @@ -63,13 +69,13 @@ export class StellarExplainClient {
/** In-flight deduplication map: cache key → shared network Promise. */
private readonly inFlight = new Map<string, Promise<unknown>>();

constructor(config: StellarExplainClientConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, "");
this.timeoutMs = config.timeoutMs ?? 30_000;
this.fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis);
this.logger = config.logger ?? NOOP_LOGGER;
this.plugins = new PluginRegistry(config.plugins);
this.cache = config.cache ?? new MemoryCache(DEFAULT_TTL_MS);
constructor(config?: StellarExplainClientConfig) {
this.baseUrl = (config?.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
this.timeoutMs = config?.timeoutMs ?? 30_000;
this.fetchImpl = config?.fetchImpl ?? globalThis.fetch.bind(globalThis);
this.logger = config?.logger ?? NOOP_LOGGER;
this.plugins = new PluginRegistry(config?.plugins);
this.cache = config?.cache ?? new MemoryCache(DEFAULT_TTL_MS);
}

/**
Expand All @@ -82,9 +88,7 @@ export class StellarExplainClient {
* @throws {@link UpstreamError} on unexpected non-JSON responses.
*/
async explainTransaction(hash: string): Promise<TransactionExplanation> {
if (!/^[0-9a-fA-F]{64}$/.test(hash)) {
throw new InvalidInputError(`Invalid transaction hash: "${hash}"`);
}
validateTransactionHash(hash);

const key = `tx:${hash}`;
const cached = this.cache.get<TransactionExplanation>(key);
Expand All @@ -94,8 +98,11 @@ export class StellarExplainClient {
}

const result = await this.dedupe(key, () =>
this.fetchJson<TransactionExplanation>(
`${this.baseUrl}/api/tx/${hash}`,
withRetry(
() => this.fetchJson<TransactionExplanation>(`${this.baseUrl}/api/tx/${hash}`),
2,
500,
(err) => !(err instanceof NotFoundError),
),
);

Expand All @@ -112,6 +119,8 @@ export class StellarExplainClient {
* @throws {@link UpstreamError} on unexpected non-JSON responses.
*/
async explainAccount(accountId: string): Promise<AccountExplanation> {
validateAccountAddress(accountId);

const key = `account:${accountId}`;
const cached = this.cache.get<AccountExplanation>(key);
if (cached !== null) {
Expand All @@ -120,8 +129,11 @@ export class StellarExplainClient {
}

const result = await this.dedupe(key, () =>
this.fetchJson<AccountExplanation>(
`${this.baseUrl}/api/account/${accountId}`,
withRetry(
() => this.fetchJson<AccountExplanation>(`${this.baseUrl}/api/account/${accountId}`),
2,
500,
(err) => !(err instanceof NotFoundError),
),
);

Expand All @@ -139,6 +151,11 @@ export class StellarExplainClient {
return this.fetchJson<HealthResponse>(`${this.baseUrl}/health`);
}

/** Clears all cached responses. */
clearCache(): void {
this.cache.clear();
}

// ── Internals ──────────────────────────────────────────────────────────────

/**
Expand Down
Loading
Loading