Skip to content
Open
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
84 changes: 84 additions & 0 deletions lib/stellar/client.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
106 changes: 106 additions & 0 deletions lib/stellar/client.ts
Original file line number Diff line number Diff line change
@@ -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.")
}
}