diff --git a/packages/sdk/README.md b/packages/sdk/README.md index d6f94a4..b047560 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -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`. -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`. -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`. -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`. +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`. +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. diff --git a/packages/sdk/src/client/StellarExplainClient.ts b/packages/sdk/src/client/StellarExplainClient.ts index 1523a4f..fe18832 100644 --- a/packages/sdk/src/client/StellarExplainClient.ts +++ b/packages/sdk/src/client/StellarExplainClient.ts @@ -1,7 +1,6 @@ import { MemoryCache } from "../cache/MemoryCache.js"; import { PluginRegistry } from "../plugins/index.js"; import { - InvalidInputError, NetworkError, NotFoundError, RateLimitError, @@ -9,6 +8,11 @@ import { TimeoutError, UpstreamError, } from "../errors/index.js"; +import { + validateTransactionHash, + validateAccountAddress, + withRetry, +} from "../utils/index.js"; import type { AccountExplanation, ApiError, @@ -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; @@ -63,13 +69,13 @@ export class StellarExplainClient { /** In-flight deduplication map: cache key → shared network Promise. */ private readonly inFlight = new Map>(); - 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); } /** @@ -82,9 +88,7 @@ export class StellarExplainClient { * @throws {@link UpstreamError} on unexpected non-JSON responses. */ async explainTransaction(hash: string): Promise { - 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(key); @@ -94,8 +98,11 @@ export class StellarExplainClient { } const result = await this.dedupe(key, () => - this.fetchJson( - `${this.baseUrl}/api/tx/${hash}`, + withRetry( + () => this.fetchJson(`${this.baseUrl}/api/tx/${hash}`), + 2, + 500, + (err) => !(err instanceof NotFoundError), ), ); @@ -112,6 +119,8 @@ export class StellarExplainClient { * @throws {@link UpstreamError} on unexpected non-JSON responses. */ async explainAccount(accountId: string): Promise { + validateAccountAddress(accountId); + const key = `account:${accountId}`; const cached = this.cache.get(key); if (cached !== null) { @@ -120,8 +129,11 @@ export class StellarExplainClient { } const result = await this.dedupe(key, () => - this.fetchJson( - `${this.baseUrl}/api/account/${accountId}`, + withRetry( + () => this.fetchJson(`${this.baseUrl}/api/account/${accountId}`), + 2, + 500, + (err) => !(err instanceof NotFoundError), ), ); @@ -139,6 +151,11 @@ export class StellarExplainClient { return this.fetchJson(`${this.baseUrl}/health`); } + /** Clears all cached responses. */ + clearCache(): void { + this.cache.clear(); + } + // ── Internals ────────────────────────────────────────────────────────────── /** diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 8f5f3dc..ec22c3a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -14,8 +14,8 @@ export { PersistentCache } from "./cache/PersistentCache.js"; // MemoryCache is intentionally NOT exported — internal use only. -export type { CacheAdapter } from "./types/index.js"; export type { + CacheAdapter, SdkErrorCode, PaymentExplanation, TransactionExplanation, @@ -23,6 +23,7 @@ export type { Signer, AccountExplanation, HealthResponse, + ApiError, StellarExplainClientConfig, SdkLogger, SdkPlugin, diff --git a/packages/sdk/src/utils/index.ts b/packages/sdk/src/utils/index.ts index 58da8fd..e85a59d 100644 --- a/packages/sdk/src/utils/index.ts +++ b/packages/sdk/src/utils/index.ts @@ -1,3 +1,5 @@ +import { InvalidInputError } from "../errors/index.js"; + /** * Runs `tasks` with at most `concurrency` promises active at a time. * Results are returned in the same order as the input array. @@ -16,7 +18,7 @@ export async function pLimit( async function worker(): Promise { while (next < tasks.length) { const i = next++; - results[i] = await tasks[i](); + results[i] = await tasks[i]!(); } } @@ -29,3 +31,62 @@ export async function pLimit( export function isValidTransactionHash(hash: string): boolean { return /^[0-9a-fA-F]{64}$/.test(hash); } + +/** + * Validates a Stellar transaction hash, throwing `InvalidInputError` if invalid. + * Must be exactly 64 hexadecimal characters. + */ +export function validateTransactionHash(hash: string): void { + if (!/^[a-fA-F0-9]{64}$/.test(hash)) { + const preview = hash.length > 16 ? `${hash.slice(0, 16)}…` : hash; + throw new InvalidInputError(`Invalid transaction hash: "${preview}"`); + } +} + +/** + * Validates a Stellar account address, throwing `InvalidInputError` if invalid. + * Must match the G-address format: G followed by 55 base-32 characters. + */ +export function validateAccountAddress(address: string): void { + if (!/^G[A-Z2-7]{55}$/.test(address)) { + const preview = address.length > 16 ? `${address.slice(0, 16)}…` : address; + throw new InvalidInputError(`Invalid account address: "${preview}"`); + } +} + +/** Resolves after `ms` milliseconds. */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Retries `fn` up to `retries` times on any thrown error. + * Delay between attempts doubles each time (exponential backoff), starting at `delayMs`. + * Pass `shouldRetry` to skip retrying on specific errors (e.g. 404s). + */ +export async function withRetry( + fn: () => Promise, + retries: number, + delayMs: number, + shouldRetry?: (err: unknown) => boolean +): Promise { + let attempt = 0; + let currentDelay = delayMs; + while (true) { + try { + return await fn(); + } catch (err) { + if (attempt >= retries || (shouldRetry && !shouldRetry(err))) throw err; + await sleep(currentDelay); + currentDelay *= 2; + attempt++; + } + } +} + +/** + * Joins `baseUrl` and `path`, stripping any trailing slash from `baseUrl` first. + */ +export function buildUrl(baseUrl: string, path: string): string { + return `${baseUrl.replace(/\/$/, "")}${path}`; +}