From 38b8befbe790ba6f49c1f61096bcaa3e710ae5e6 Mon Sep 17 00:00:00 2001 From: Errordog2 Date: Sat, 20 Jun 2026 06:15:41 +0800 Subject: [PATCH] feat: add Stellar client helper --- lib/stellar/client.test.ts | 84 +++++++++++++++++++++++++++++ lib/stellar/client.ts | 106 +++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 lib/stellar/client.test.ts create mode 100644 lib/stellar/client.ts diff --git a/lib/stellar/client.test.ts b/lib/stellar/client.test.ts new file mode 100644 index 0000000..8c2ff3f --- /dev/null +++ b/lib/stellar/client.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + createStellarClients, + getStellarClientConfig, + normalizeStellarNetwork, +} from "./client" + +describe("stellar client helper", () => { + const originalEnv = { ...process.env } + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-06-20T00:00:00Z")) + delete process.env.STELLAR_NETWORK + delete process.env.STELLAR_HORIZON_URL + delete process.env.STELLAR_RPC_URL + delete process.env.RPC_URL + }) + + afterEach(() => { + vi.useRealTimers() + process.env = { ...originalEnv } + }) + + it("defaults to testnet Horizon and Soroban RPC endpoints", () => { + const config = getStellarClientConfig() + + expect(config).toEqual({ + network: "testnet", + horizonUrl: "https://horizon-testnet.stellar.org", + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + }) + }) + + it("supports mainnet selection without requiring env URL overrides", () => { + const config = getStellarClientConfig("mainnet") + + expect(config).toEqual({ + network: "mainnet", + horizonUrl: "https://horizon.stellar.org", + rpcUrl: "https://soroban-mainnet.stellar.org", + networkPassphrase: "Public Global Stellar Network ; September 2015", + }) + }) + + it("uses the Stellar config helper network by default", () => { + process.env.STELLAR_NETWORK = "mainnet" + + expect(getStellarClientConfig().network).toBe("mainnet") + }) + + it("keeps explicit Horizon and RPC env overrides", () => { + process.env.STELLAR_HORIZON_URL = "https://horizon.example.org/" + process.env.STELLAR_RPC_URL = "https://rpc.example.org/" + + const config = getStellarClientConfig("testnet") + + expect(config.horizonUrl).toBe("https://horizon.example.org") + expect(config.rpcUrl).toBe("https://rpc.example.org") + }) + + it("rejects unsupported network names", () => { + expect(() => normalizeStellarNetwork("devnet")).toThrow( + /Unsupported Stellar network/, + ) + }) + + it("builds Horizon URLs and Soroban JSON-RPC requests from one config", () => { + const clients = createStellarClients("testnet") + + expect(clients.horizon.buildUrl("/accounts/GABC")).toBe( + "https://horizon-testnet.stellar.org/accounts/GABC", + ) + expect(clients.soroban.buildJsonRpcRequest("getHealth")).toEqual({ + jsonrpc: "2.0", + id: "getHealth-1781913600000", + method: "getHealth", + }) + expect(clients.config.network).toBe("testnet") + }) +}) diff --git a/lib/stellar/client.ts b/lib/stellar/client.ts new file mode 100644 index 0000000..c48369a --- /dev/null +++ b/lib/stellar/client.ts @@ -0,0 +1,106 @@ +import { getStellarConfig } from "@/lib/stellar/config" + +export type StellarNetwork = "testnet" | "mainnet" + +export interface StellarClientConfig { + network: StellarNetwork + horizonUrl: string + rpcUrl: string + networkPassphrase: string +} + +export interface StellarClientEndpoints { + config: StellarClientConfig + horizon: { + baseUrl: string + buildUrl: (path: string) => string + } + soroban: { + rpcUrl: string + buildJsonRpcRequest: (method: string, params?: unknown) => { + jsonrpc: "2.0" + id: string + method: string + params?: unknown + } + } +} + +const NETWORK_DEFAULTS: Record< + StellarNetwork, + { horizonUrl: string; rpcUrl: string; networkPassphrase: string } +> = { + testnet: { + horizonUrl: "https://horizon-testnet.stellar.org", + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + }, + mainnet: { + horizonUrl: "https://horizon.stellar.org", + rpcUrl: "https://soroban-mainnet.stellar.org", + networkPassphrase: "Public Global Stellar Network ; September 2015", + }, +} + +export function normalizeStellarNetwork(network?: string): StellarNetwork { + const normalized = (network || "testnet").trim().toLowerCase() + + if (normalized === "testnet") return "testnet" + if (normalized === "mainnet" || normalized === "public") return "mainnet" + + throw new Error( + `Unsupported Stellar network "${network}". Use "testnet" or "mainnet".`, + ) +} + +export function getStellarClientConfig( + network = getStellarConfig().network, +): StellarClientConfig { + assertServerOnly() + + const selectedNetwork = normalizeStellarNetwork(network) + const defaults = NETWORK_DEFAULTS[selectedNetwork] + + return { + network: selectedNetwork, + horizonUrl: ( + process.env.STELLAR_HORIZON_URL || defaults.horizonUrl + ).replace(/\/$/, ""), + rpcUrl: ( + process.env.STELLAR_RPC_URL || + process.env.RPC_URL || + defaults.rpcUrl + ).replace(/\/$/, ""), + networkPassphrase: defaults.networkPassphrase, + } +} + +export function createStellarClients(network?: string): StellarClientEndpoints { + const config = getStellarClientConfig(network) + + return { + config, + horizon: { + baseUrl: config.horizonUrl, + buildUrl: (path: string) => { + const normalizedPath = path.startsWith("/") ? path : `/${path}` + return `${config.horizonUrl}${normalizedPath}` + }, + }, + soroban: { + rpcUrl: config.rpcUrl, + buildJsonRpcRequest: (method: string, params?: unknown) => ({ + jsonrpc: "2.0", + id: `${method}-${Date.now()}`, + method, + ...(params === undefined ? {} : { params }), + }), + }, + } +} + +function assertServerOnly() { + if (typeof window !== "undefined") { + throw new Error("Stellar client helpers are server-only.") + } +}