From 09d8a4e7d9a3e983dabfe586261b56f3b8e6d987 Mon Sep 17 00:00:00 2001 From: namdamdoi68-oss Date: Thu, 30 Jul 2026 10:24:22 +0700 Subject: [PATCH] feat(config): add multi-network environment configuration switcher Extends SDK with NetworkEnvironment enum presets and switchNetwork method that validates live RPC passphrase before switching endpoints. Signed-off-by: namdamdoi68-oss --- src/client.ts | 123 +++++++++++++++++------ src/config.ts | 37 +++++++ src/errors.ts | 19 ++++ src/index.ts | 7 ++ test/networkSwitcher.test.ts | 187 +++++++++++++++++++++++++++++++++++ 5 files changed, 345 insertions(+), 28 deletions(-) create mode 100644 src/config.ts create mode 100644 test/networkSwitcher.test.ts diff --git a/src/client.ts b/src/client.ts index d7b0d69..554e28d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -195,9 +195,13 @@ import { StellarSplitError, AdminOperationError, PassphraseMismatchError, - InvoiceIntegrityError, - InvalidTransactionTypeError, + NetworkMismatchError, } from "./errors.js"; +import { + NetworkEnvironment, + NETWORK_PRESETS, +} from "./config.js"; +import type { NetworkPreset } from "./config.js"; import { hashInvoice, verifyInvoiceHash } from "./invoiceHashVerifier.js"; import { buildFeeBump } from "./feeBumpBuilder.js"; import type { FeeBumpConfig } from "./feeBumpBuilder.js"; @@ -3191,6 +3195,13 @@ export class StellarSplitClient extends TypedEventEmitter { return checkRPCHealth(this.server); } + /** + * Returns a copy of the client configuration. + */ + public getConfig(): StellarSplitClientConfig { + return this.config; + } + /** * Create a group of linked invoices. * @@ -4021,52 +4032,108 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Switch to a different network. - * - * @param network - Network name ('testnet', 'mainnet') or custom NetworkConfig + * Reconfigures internal Horizon and Soroban RPC URL references and validates + * that the active network passphrase matches the live RPC endpoint's reported + * passphrase before accepting the network switch. */ - switchNetwork(network: string | NetworkConfig): void { - let config: NetworkConfig; - - if (typeof network === "string") { - const preset = NETWORKS[network]; - if (!preset) { - throw new UnknownNetworkError(network); + public switchNetwork( + env: Exclude, + ): Promise; + public switchNetwork( + env: NetworkEnvironment.CUSTOM, + preset: NetworkPreset, + ): Promise; + public switchNetwork(preset: NetworkPreset): Promise; + public async switchNetwork( + envOrPreset: NetworkEnvironment | NetworkPreset, + customPreset?: NetworkPreset, + ): Promise { + let preset: NetworkPreset; + + if (typeof envOrPreset === "string") { + if (envOrPreset === NetworkEnvironment.CUSTOM) { + if (!customPreset) { + throw new StellarSplitError( + "Passing NetworkEnvironment.CUSTOM requires a full NetworkPreset object", + "INVALID_NETWORK_PRESET", + ); + } + preset = customPreset; + } else { + const builtIn = + NETWORK_PRESETS[ + envOrPreset as Exclude + ]; + if (!builtIn) { + throw new StellarSplitError( + `Unknown network environment: ${envOrPreset}`, + "UNKNOWN_NETWORK_ENVIRONMENT", + ); + } + preset = builtIn; } - config = { ...preset, contractId: this.config.contractId }; } else { - config = network; + preset = envOrPreset; } - this.config = config; - this.server = new SorobanRpc.Server(config.rpcUrl, { - allowHttp: config.rpcUrl.startsWith("http://"), + if (!preset.horizonUrl || !preset.rpcUrl || !preset.networkPassphrase) { + throw new StellarSplitError( + "NetworkPreset must specify horizonUrl, rpcUrl, and networkPassphrase", + "INVALID_NETWORK_PRESET", + ); + } + + const tempServer = new SorobanRpc.Server(preset.rpcUrl, { + allowHttp: preset.rpcUrl.startsWith("http://"), }); - // Rebuild the connection pool for the new endpoint. We read from - // `_effectiveRpcPoolSize` (cached at construction) rather than - // `this.config.rpcPoolSize` here because `NetworkConfig` doesn't carry a - // pool size — reading from `this.config` after `this.config = config` - // above would silently disable pooling on every network switch. + let livePassphrase: string; + try { + const networkInfo = await tempServer.getNetwork(); + livePassphrase = networkInfo.passphrase; + } catch (err) { + throw new StellarSplitError( + `Failed to reach RPC endpoint ${preset.rpcUrl} to verify network passphrase: ${ + err instanceof Error ? err.message : String(err) + }`, + "RPC_NETWORK_CHECK_FAILED", + { rpcUrl: preset.rpcUrl }, + err instanceof Error ? err.stack : undefined, + ); + } + + if (livePassphrase !== preset.networkPassphrase) { + throw new NetworkMismatchError(preset.networkPassphrase, livePassphrase); + } + + this.config.horizonUrl = preset.horizonUrl; + this.config.rpcUrl = preset.rpcUrl; + this.config.networkPassphrase = preset.networkPassphrase; + this._mainServer = tempServer; + if (preset.horizonUrl) { + this._horizonReader = new HorizonFallbackReader(preset.horizonUrl); + } + if (this._pool) { this._pool.dispose(); this._pool = null; } - const wantsPool = !this._standby && this._effectiveRpcPoolSize >= 2; + const wantsPool = !this._standby && (this._effectiveRpcPoolSize ?? 0) >= 2; if (wantsPool) { try { this._pool = new ConnectionPool({ - rpcUrl: config.rpcUrl, + rpcUrl: preset.rpcUrl, poolSize: this._effectiveRpcPoolSize, - allowHttp: config.rpcUrl.startsWith("http://"), + allowHttp: preset.rpcUrl.startsWith("http://"), }); } catch { - // The Soroban SDK can reject bare http:// without allowHttp or ws:// URLs. - // Fail open so switchNetwork() stays a no-op rather than crashing the SDK. + // Fail open so switchNetwork() stays resilient } } - this.contract = new Contract(config.contractId); + if (this.config.contractId) { + this.contract = new Contract(this.config.contractId); + } } // --------------------------------------------------------------------------- diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b1c71a0 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,37 @@ +/** + * Multi-Network Environment Configuration for StellarSplit SDK. + */ + +export enum NetworkEnvironment { + MAINNET = "MAINNET", + TESTNET = "TESTNET", + FUTURENET = "FUTURENET", + CUSTOM = "CUSTOM", +} + +export interface NetworkPreset { + horizonUrl: string; + rpcUrl: string; + networkPassphrase: string; +} + +export const NETWORK_PRESETS: Record< + Exclude, + NetworkPreset +> = { + [NetworkEnvironment.MAINNET]: { + horizonUrl: "https://horizon.stellar.org", + rpcUrl: "https://soroban-rpc.mainnet.stellar.org", + networkPassphrase: "Public Global Stellar Network ; September 2015", + }, + [NetworkEnvironment.TESTNET]: { + horizonUrl: "https://horizon-testnet.stellar.org", + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; July 2015", + }, + [NetworkEnvironment.FUTURENET]: { + horizonUrl: "https://horizon-futurenet.stellar.org", + rpcUrl: "https://rpc-futurenet.stellar.org", + networkPassphrase: "Test SDF Future Network ; October 2022", + }, +}; diff --git a/src/errors.ts b/src/errors.ts index 47f1a5c..753f162 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1699,3 +1699,22 @@ export class ClassifiedHorizonError extends StellarSplitError { export function isClassifiedHorizonError(err: unknown): err is ClassifiedHorizonError { return err instanceof ClassifiedHorizonError; } + +/** Thrown when the live RPC passphrase does not match the expected network preset. */ +export class NetworkMismatchError extends StellarSplitError { + readonly expected: string; + readonly actual: string; + + constructor(expected: string, actual: string, raw?: string) { + super( + `Network mismatch: expected "${expected}" but RPC reported "${actual}"`, + "NETWORK_MISMATCH", + { expected, actual }, + raw + ); + this.name = "NetworkMismatchError"; + this.expected = expected; + this.actual = actual; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/src/index.ts b/src/index.ts index 38d8fe1..0468016 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,8 +28,15 @@ export { serializeInvoiceTemplate, deserializeInvoiceTemplate, } from "./invoiceTemplate.js"; +export { + NetworkEnvironment, + NETWORK_PRESETS, +} from "./config.js"; +export type { NetworkPreset } from "./config.js"; + export { StellarSplitError, + NetworkMismatchError, InvoiceNotFoundError, InvoiceNotPendingError, DeadlinePassedError, diff --git a/test/networkSwitcher.test.ts b/test/networkSwitcher.test.ts new file mode 100644 index 0000000..f5fad38 --- /dev/null +++ b/test/networkSwitcher.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + StellarSplitClient, + NetworkEnvironment, + NETWORK_PRESETS, + NetworkMismatchError, + StellarSplitError, +} from "../src/index.js"; +import { rpc as SorobanRpc } from "@stellar/stellar-sdk"; + +describe("Multi-Network Environment Configuration Switcher (#587)", () => { + const TEST_CONTRACT_ID = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("should successfully switch to TESTNET when passphrase matches", async () => { + const preset = NETWORK_PRESETS[NetworkEnvironment.TESTNET]; + + const client = new StellarSplitClient({ + contractId: TEST_CONTRACT_ID, + rpcUrl: NETWORK_PRESETS[NetworkEnvironment.MAINNET].rpcUrl, + horizonUrl: NETWORK_PRESETS[NetworkEnvironment.MAINNET].horizonUrl, + networkPassphrase: NETWORK_PRESETS[NetworkEnvironment.MAINNET].networkPassphrase, + validateNetwork: false, + }); + + const getNetworkSpy = vi + .spyOn(SorobanRpc.Server.prototype, "getNetwork") + .mockResolvedValue({ + passphrase: preset.networkPassphrase, + protocolVersion: 20, + }); + + await client.switchNetwork(NetworkEnvironment.TESTNET); + + expect(getNetworkSpy).toHaveBeenCalled(); + expect(client.getConfig().rpcUrl).toBe(preset.rpcUrl); + expect(client.getConfig().horizonUrl).toBe(preset.horizonUrl); + expect(client.getConfig().networkPassphrase).toBe(preset.networkPassphrase); + }); + + it("should throw NetworkMismatchError when RPC reported passphrase does not match preset", async () => { + const testnetPreset = NETWORK_PRESETS[NetworkEnvironment.TESTNET]; + const expectedPassphrase = testnetPreset.networkPassphrase; + + const client = new StellarSplitClient({ + contractId: TEST_CONTRACT_ID, + rpcUrl: NETWORK_PRESETS[NetworkEnvironment.MAINNET].rpcUrl, + horizonUrl: NETWORK_PRESETS[NetworkEnvironment.MAINNET].horizonUrl, + networkPassphrase: NETWORK_PRESETS[NetworkEnvironment.MAINNET].networkPassphrase, + validateNetwork: false, + }); + + vi.spyOn(SorobanRpc.Server.prototype, "getNetwork").mockResolvedValue({ + passphrase: "Wrong Passphrase ; December 2025", + protocolVersion: 20, + }); + + await expect(client.switchNetwork(NetworkEnvironment.TESTNET)).rejects.toThrow( + NetworkMismatchError, + ); + + try { + await client.switchNetwork(NetworkEnvironment.TESTNET); + } catch (err) { + expect(err).toBeInstanceOf(NetworkMismatchError); + const mismatchErr = err as NetworkMismatchError; + expect(mismatchErr.expected).toBe(expectedPassphrase); + expect(mismatchErr.actual).toBe("Wrong Passphrase ; December 2025"); + expect(mismatchErr.code).toBe("NETWORK_MISMATCH"); + } + + expect(client.getConfig().rpcUrl).toBe(NETWORK_PRESETS[NetworkEnvironment.MAINNET].rpcUrl); + }); + + it("should support CUSTOM environment with a full NetworkPreset object", async () => { + const customPreset = { + horizonUrl: "https://custom-horizon.example.com", + rpcUrl: "https://custom-rpc.example.com", + networkPassphrase: "Custom Private Network ; 2026", + }; + + const client = new StellarSplitClient({ + contractId: TEST_CONTRACT_ID, + rpcUrl: NETWORK_PRESETS[NetworkEnvironment.TESTNET].rpcUrl, + networkPassphrase: NETWORK_PRESETS[NetworkEnvironment.TESTNET].networkPassphrase, + validateNetwork: false, + }); + + vi.spyOn(SorobanRpc.Server.prototype, "getNetwork").mockResolvedValue({ + passphrase: customPreset.networkPassphrase, + protocolVersion: 20, + }); + + await client.switchNetwork(NetworkEnvironment.CUSTOM, customPreset); + + expect(client.getConfig().rpcUrl).toBe(customPreset.rpcUrl); + expect(client.getConfig().horizonUrl).toBe(customPreset.horizonUrl); + expect(client.getConfig().networkPassphrase).toBe(customPreset.networkPassphrase); + }); + + it("should support passing NetworkPreset object directly", async () => { + const customPreset = { + horizonUrl: "https://direct-horizon.example.com", + rpcUrl: "https://direct-rpc.example.com", + networkPassphrase: "Direct Network Passphrase", + }; + + const client = new StellarSplitClient({ + contractId: TEST_CONTRACT_ID, + rpcUrl: NETWORK_PRESETS[NetworkEnvironment.TESTNET].rpcUrl, + networkPassphrase: NETWORK_PRESETS[NetworkEnvironment.TESTNET].networkPassphrase, + validateNetwork: false, + }); + + vi.spyOn(SorobanRpc.Server.prototype, "getNetwork").mockResolvedValue({ + passphrase: customPreset.networkPassphrase, + protocolVersion: 20, + }); + + await client.switchNetwork(customPreset); + + expect(client.getConfig().rpcUrl).toBe(customPreset.rpcUrl); + expect(client.getConfig().horizonUrl).toBe(customPreset.horizonUrl); + }); + + it("should throw StellarSplitError when CUSTOM environment is passed without preset", async () => { + const client = new StellarSplitClient({ + contractId: TEST_CONTRACT_ID, + rpcUrl: NETWORK_PRESETS[NetworkEnvironment.TESTNET].rpcUrl, + networkPassphrase: NETWORK_PRESETS[NetworkEnvironment.TESTNET].networkPassphrase, + validateNetwork: false, + }); + + // @ts-expect-error Testing runtime check for CUSTOM without preset + await expect(client.switchNetwork(NetworkEnvironment.CUSTOM)).rejects.toThrow( + StellarSplitError, + ); + }); + + it("should preserve in-flight requests during network switchover window", async () => { + let resolveGetNetwork: (val: any) => void; + const getNetworkPromise = new Promise((resolve) => { + resolveGetNetwork = resolve; + }); + + const testnetPreset = NETWORK_PRESETS[NetworkEnvironment.TESTNET]; + + const client = new StellarSplitClient({ + contractId: TEST_CONTRACT_ID, + rpcUrl: NETWORK_PRESETS[NetworkEnvironment.MAINNET].rpcUrl, + horizonUrl: NETWORK_PRESETS[NetworkEnvironment.MAINNET].horizonUrl, + networkPassphrase: NETWORK_PRESETS[NetworkEnvironment.MAINNET].networkPassphrase, + validateNetwork: false, + }); + + vi.spyOn(SorobanRpc.Server.prototype, "getNetwork").mockImplementation(() => { + return getNetworkPromise as Promise; + }); + + // Capture initial server reference for in-flight operation + const inFlightServer = client.server; + const initialRpcUrl = client.getConfig().rpcUrl; + + // Trigger asynchronous switchNetwork (starts pending network call) + const switchPromise = client.switchNetwork(NetworkEnvironment.TESTNET); + + // Requests issued during switchover window still see current active server reference until resolved + expect(inFlightServer.serverURL.toString()).toContain("mainnet"); + + // Resolve network check + resolveGetNetwork!({ + passphrase: testnetPreset.networkPassphrase, + protocolVersion: 20, + }); + + await switchPromise; + + // After switch completes, client.server points to new endpoint + expect(client.getConfig().rpcUrl).toBe(testnetPreset.rpcUrl); + // In-flight server reference retained original URL + expect(inFlightServer.serverURL.toString()).not.toBe(client.server.serverURL.toString()); + expect(initialRpcUrl).not.toBe(client.getConfig().rpcUrl); + }); +});