From af5054e442f134c40e4849885b57e8473f9637cb Mon Sep 17 00:00:00 2001 From: Isenewo Oluwaseyi Ephraim Date: Mon, 30 Mar 2026 12:37:48 +0100 Subject: [PATCH] feat(backend): implement Soroban/Stellar RPC client abstraction with retry policy Body: - add typed Soroban client for simulate, submit, and ledger query operations - implement timeout and exponential backoff constants for idempotent read calls - avoid automatic retries for write submissions to reduce duplicate-send risk - add env config for SOROBAN_RPC_URL and SOROBAN_NETWORK_PASSPHRASE - add unit tests with mocked HTTP adapter for retry and failure paths - document RPC wrapper behavior and security notes in README --- README.md | 23 +++ src/clients/sorobanClient.test.ts | 192 ++++++++++++++++++ src/clients/sorobanClient.ts | 312 ++++++++++++++++++++++++++++++ src/config/env.test.ts | 21 ++ src/config/env.ts | 2 + 5 files changed, 550 insertions(+) create mode 100644 src/clients/sorobanClient.test.ts create mode 100644 src/clients/sorobanClient.ts diff --git a/README.md b/README.md index b3159b4..b9a990c 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,29 @@ Security notes: - Replay protection is enforced by deduplicating `eventId` values in the ingestion service. - Duplicate deliveries are treated as safe no-ops and return `202 Accepted`. +## Soroban / Stellar RPC Client Wrapper + +`src/clients/sorobanClient.ts` provides a typed JSON-RPC client for StreamPay contract and ledger operations. + +Implemented operations: +- `simulateContractCall` (idempotent read, retried with exponential backoff) +- `getLedgerEntry` (idempotent read, retried with exponential backoff) +- `sendTransaction` (write path, single-attempt by default) + +Retry and timeout policy constants: +- `SOROBAN_RPC_TIMEOUT_MS = 8000` +- `SOROBAN_RPC_MAX_RETRIES = 3` +- `SOROBAN_RPC_RETRY_BASE_DELAY_MS = 200` + +Required environment variables: +- `SOROBAN_RPC_URL` +- `SOROBAN_NETWORK_PASSPHRASE` + +Security notes: +- Reads are treated as idempotent and safe to retry. +- Writes are not retried by default to avoid accidental duplicate submissions. +- The wrapper avoids logging secrets and only includes method-level failure context. + ## API Versioning Policy All new features and endpoints must be mounted under the `/api/v1` prefix. diff --git a/src/clients/sorobanClient.test.ts b/src/clients/sorobanClient.test.ts new file mode 100644 index 0000000..1497209 --- /dev/null +++ b/src/clients/sorobanClient.test.ts @@ -0,0 +1,192 @@ +import { + HttpAdapter, + SOROBAN_RPC_RETRY_BASE_DELAY_MS, + SOROBAN_RPC_MAX_RETRIES, + SOROBAN_RPC_TIMEOUT_MS, + SorobanClient, +} from "./sorobanClient"; + +declare const describe: (name: string, run: () => void) => void; +declare const it: (name: string, run: () => void | Promise) => void; +declare const expect: { + (value: unknown): { + toBe: (expected: unknown) => void; + toThrow: (expected: string) => Promise | void; + rejects: { + toThrow: (expected: string) => Promise; + }; + }; +}; + +type MockFn = ((...args: unknown[]) => unknown) & { + mock: { + calls: unknown[][]; + }; +}; + +declare const jest: { + fn: (impl?: (...args: unknown[]) => unknown | Promise) => MockFn; +}; + +describe("SorobanClient", () => { + const createAdapter = (responses: Array, status = 200): { + adapter: HttpAdapter; + sendMock: MockFn; + } => { + let index = 0; + + const sendMock = jest.fn(async () => { + const next = responses[Math.min(index, responses.length - 1)]; + index += 1; + + if (next instanceof Error) { + throw next; + } + + return { + status, + json: next, + }; + }); + + return { + adapter: { + send: sendMock as unknown as HttpAdapter["send"], + }, + sendMock, + }; + }; + + it("exports retry policy constants", () => { + expect(SOROBAN_RPC_TIMEOUT_MS).toBe(8000); + expect(SOROBAN_RPC_MAX_RETRIES).toBe(3); + expect(SOROBAN_RPC_RETRY_BASE_DELAY_MS).toBe(200); + }); + + it("retries idempotent read and succeeds after transient failures", async () => { + const { adapter, sendMock } = createAdapter([ + new Error("temporary network issue"), + { + jsonrpc: "2.0", + id: "abc", + result: { + latestLedger: 123, + entries: [{ key: "ledger-key" }], + }, + }, + ]); + + const client = new SorobanClient( + { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + timeoutMs: 100, + maxRetries: 2, + baseDelayMs: 1, + }, + adapter, + ); + + const result = await client.getLedgerEntry({ key: "AAAA" }); + + expect(result.latestLedger).toBe(123); + expect(Array.isArray(result.entries)).toBe(true); + expect(sendMock.mock.calls.length).toBe(2); + }); + + it("does not retry write operations", async () => { + const { adapter, sendMock } = createAdapter([new Error("submit failed")]); + + const client = new SorobanClient( + { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + timeoutMs: 50, + maxRetries: 3, + baseDelayMs: 1, + }, + adapter, + ); + + await expect(client.sendTransaction({ transaction: "AAAA" })).rejects.toThrow( + "Soroban RPC sendTransaction failed (write)", + ); + expect(sendMock.mock.calls.length).toBe(1); + }); + + it("surfaces RPC errors after retry exhaustion", async () => { + const { adapter, sendMock } = createAdapter([ + { + jsonrpc: "2.0", + id: "1", + error: { + code: -32000, + message: "overloaded", + }, + }, + { + jsonrpc: "2.0", + id: "2", + error: { + code: -32000, + message: "still overloaded", + }, + }, + ]); + + const client = new SorobanClient( + { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + timeoutMs: 100, + maxRetries: 1, + baseDelayMs: 1, + }, + adapter, + ); + + await expect(client.simulateContractCall({ transaction: "AAAA" })).rejects.toThrow( + "Soroban RPC simulateTransaction failed (idempotent read)", + ); + expect(sendMock.mock.calls.length).toBe(2); + }); + + it("sends passphrase and JSON-RPC payload via HTTP adapter", async () => { + const { adapter, sendMock } = createAdapter([ + { + jsonrpc: "2.0", + id: "read-1", + result: { + latestLedger: 44, + transactionData: "tx-data", + minResourceFee: "10", + }, + }, + ]); + + const client = new SorobanClient( + { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + timeoutMs: 100, + maxRetries: 0, + baseDelayMs: 1, + }, + adapter, + ); + + const result = await client.simulateContractCall({ transaction: "AAAA" }); + + expect(result.transactionData).toBe("tx-data"); + + const call = sendMock.mock.calls[0][0] as { + headers: Record; + body: string; + }; + expect(call.headers["x-network-passphrase"]).toBe("Test SDF Network ; September 2015"); + + const parsedBody = JSON.parse(call.body); + expect(parsedBody.jsonrpc).toBe("2.0"); + expect(parsedBody.method).toBe("simulateTransaction"); + }); +}); diff --git a/src/clients/sorobanClient.ts b/src/clients/sorobanClient.ts new file mode 100644 index 0000000..6d0ef9e --- /dev/null +++ b/src/clients/sorobanClient.ts @@ -0,0 +1,312 @@ +export const SOROBAN_RPC_TIMEOUT_MS = 8_000; +export const SOROBAN_RPC_MAX_RETRIES = 3; +export const SOROBAN_RPC_RETRY_BASE_DELAY_MS = 200; + +declare const setTimeout: (handler: (...args: unknown[]) => void, timeout?: number) => unknown; + +export interface SorobanClientConfig { + rpcUrl: string; + networkPassphrase: string; + timeoutMs?: number; + maxRetries?: number; + baseDelayMs?: number; +} + +export interface HttpAdapterRequest { + url: string; + method: "POST"; + headers: Record; + body: string; + timeoutMs: number; +} + +export interface HttpAdapterResponse { + status: number; + json: unknown; +} + +export interface HttpAdapter { + send(request: HttpAdapterRequest): Promise; +} + +export interface SimulateContractCallParams { + transaction: string; + resourceConfig?: Record; +} + +export interface SimulateContractCallResult { + id: string; + latestLedger?: number; + transactionData?: string; + minResourceFee?: string; + raw: unknown; +} + +export interface SendTransactionParams { + transaction: string; +} + +export interface SendTransactionResult { + id: string; + hash?: string; + status?: string; + raw: unknown; +} + +export interface GetLedgerEntryParams { + key: string; +} + +export interface GetLedgerEntryResult { + id: string; + latestLedger?: number; + entries?: unknown[]; + raw: unknown; +} + +export interface JsonRpcSuccess { + jsonrpc: "2.0"; + id: string; + result: T; +} + +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +export interface JsonRpcFailure { + jsonrpc: "2.0"; + id: string; + error: JsonRpcError; +} + +export type JsonRpcResponse = JsonRpcSuccess | JsonRpcFailure; + +class FetchHttpAdapter implements HttpAdapter { + async send(request: HttpAdapterRequest): Promise { + const fetchRef = (globalThis as unknown as { + fetch?: (url: string, init?: { + method?: string; + headers?: Record; + body?: string; + }) => Promise<{ + status: number; + json: () => Promise; + }>; + }).fetch; + + if (!fetchRef) { + throw new Error("Global fetch is unavailable in runtime."); + } + + const response = await fetchRef(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + }); + + const json = await response.json().catch(() => null); + return { + status: response.status, + json, + }; + } +} + +export class SorobanClient { + private readonly config: Required>; + private readonly httpAdapter: HttpAdapter; + + constructor(config: SorobanClientConfig, httpAdapter: HttpAdapter = new FetchHttpAdapter()) { + this.config = { + rpcUrl: config.rpcUrl, + networkPassphrase: config.networkPassphrase, + timeoutMs: config.timeoutMs ?? SOROBAN_RPC_TIMEOUT_MS, + maxRetries: config.maxRetries ?? SOROBAN_RPC_MAX_RETRIES, + baseDelayMs: config.baseDelayMs ?? SOROBAN_RPC_RETRY_BASE_DELAY_MS, + }; + this.httpAdapter = httpAdapter; + } + + async simulateContractCall(params: SimulateContractCallParams): Promise { + const response = await this.rpcCall>( + "simulateTransaction", + { + transaction: params.transaction, + resourceConfig: params.resourceConfig, + }, + { retry: true, idempotentRead: true }, + ); + + return { + id: response.id, + latestLedger: this.toNumber(response.result.latestLedger), + transactionData: this.toString(response.result.transactionData), + minResourceFee: this.toString(response.result.minResourceFee), + raw: response.result, + }; + } + + async sendTransaction(params: SendTransactionParams): Promise { + const response = await this.rpcCall>( + "sendTransaction", + { + transaction: params.transaction, + }, + { retry: false, idempotentRead: false }, + ); + + return { + id: response.id, + hash: this.toString(response.result.hash), + status: this.toString(response.result.status), + raw: response.result, + }; + } + + async getLedgerEntry(params: GetLedgerEntryParams): Promise { + const response = await this.rpcCall>( + "getLedgerEntries", + { + keys: [params.key], + }, + { retry: true, idempotentRead: true }, + ); + + const entriesValue = response.result.entries; + + return { + id: response.id, + latestLedger: this.toNumber(response.result.latestLedger), + entries: Array.isArray(entriesValue) ? entriesValue : undefined, + raw: response.result, + }; + } + + private async rpcCall>( + method: string, + params: Record, + options: { retry: boolean; idempotentRead: boolean }, + ): Promise> { + const attemptLimit = options.retry ? this.config.maxRetries + 1 : 1; + + let attempt = 0; + while (attempt < attemptLimit) { + attempt += 1; + + try { + const payload = { + jsonrpc: "2.0", + id: this.makeRequestId(), + method, + params, + }; + + const response = await this.withTimeout( + this.httpAdapter.send({ + url: this.config.rpcUrl, + method: "POST", + headers: { + "content-type": "application/json", + "x-network-passphrase": this.config.networkPassphrase, + }, + body: JSON.stringify(payload), + timeoutMs: this.config.timeoutMs, + }), + this.config.timeoutMs, + ); + + if (response.status < 200 || response.status >= 300) { + throw new Error(`Soroban RPC HTTP ${response.status}`); + } + + const parsed = this.parseResponse(response.json); + if ("error" in parsed) { + throw new Error(`Soroban RPC ${parsed.error.code}: ${parsed.error.message}`); + } + + return parsed; + } catch (error) { + const isLastAttempt = attempt >= attemptLimit; + if (isLastAttempt) { + throw this.toSafeError(method, error, options.idempotentRead); + } + + await this.sleep(this.backoffDelayForAttempt(attempt)); + } + } + + throw new Error("Unreachable retry state"); + } + + private parseResponse>(value: unknown): JsonRpcResponse { + if (!value || typeof value !== "object") { + throw new Error("Invalid JSON-RPC response format"); + } + + const candidate = value as Record; + + if (candidate.jsonrpc !== "2.0") { + throw new Error("Unexpected JSON-RPC version"); + } + + if (typeof candidate.id !== "string") { + throw new Error("Missing JSON-RPC response id"); + } + + if (candidate.error && typeof candidate.error === "object") { + const rpcError = candidate.error as Record; + return { + jsonrpc: "2.0", + id: candidate.id, + error: { + code: typeof rpcError.code === "number" ? rpcError.code : -1, + message: typeof rpcError.message === "string" ? rpcError.message : "Unknown RPC error", + data: rpcError.data, + }, + }; + } + + return { + jsonrpc: "2.0", + id: candidate.id, + result: (candidate.result ?? {}) as T, + }; + } + + private async withTimeout(promise: Promise, timeoutMs: number): Promise { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs); + }); + + return Promise.race([promise, timeoutPromise]); + } + + private backoffDelayForAttempt(attempt: number): number { + return this.config.baseDelayMs * (2 ** (attempt - 1)); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(() => resolve(), ms)); + } + + private makeRequestId(): string { + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; + } + + private toString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; + } + + private toNumber(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; + } + + private toSafeError(method: string, error: unknown, idempotentRead: boolean): Error { + const detail = error instanceof Error ? error.message : "Unknown error"; + const operationType = idempotentRead ? "idempotent read" : "write"; + return new Error(`Soroban RPC ${method} failed (${operationType}): ${detail}`); + } +} diff --git a/src/config/env.test.ts b/src/config/env.test.ts index 605caee..5228871 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -9,6 +9,8 @@ describe("Environment Configuration Schema", () => { DATABASE_URL: "postgres://localhost:5432/db", JWT_SECRET: "a_very_long_secret_that_is_at_least_32_characters", RPC_URL: "https://api.mainnet-beta.solana.com", + SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org", + SOROBAN_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015", NODE_ENV: "development", }; @@ -49,6 +51,25 @@ describe("Environment Configuration Schema", () => { } }); + it("should fail if SOROBAN_RPC_URL is not a valid URL", () => { + const invalidEnv = { ...validEnv, SOROBAN_RPC_URL: "bad-url" }; + const result = envSchema.safeParse(invalidEnv); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors).toHaveProperty("SOROBAN_RPC_URL"); + } + }); + + it("should fail if SOROBAN_NETWORK_PASSPHRASE is missing", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { SOROBAN_NETWORK_PASSPHRASE, ...invalidEnv } = validEnv; + const result = envSchema.safeParse(invalidEnv); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors).toHaveProperty("SOROBAN_NETWORK_PASSPHRASE"); + } + }); + it("should default PORT to 3001 if missing", () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { PORT, ...envWithoutPort } = validEnv; diff --git a/src/config/env.ts b/src/config/env.ts index 9e6cfce..98f402e 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -9,6 +9,8 @@ export const envSchema = z.object({ DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"), RPC_URL: z.string().url(), + SOROBAN_RPC_URL: z.string().url(), + SOROBAN_NETWORK_PASSPHRASE: z.string().min(1, "SOROBAN_NETWORK_PASSPHRASE is required"), NODE_ENV: z.enum(["development", "production", "test"]).default("development"), });