Skip to content
Closed
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
123 changes: 95 additions & 28 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -3191,6 +3195,13 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
return checkRPCHealth(this.server);
}

/**
* Returns a copy of the client configuration.
*/
public getConfig(): StellarSplitClientConfig {
return this.config;
}

/**
* Create a group of linked invoices.
*
Expand Down Expand Up @@ -4021,52 +4032,108 @@ export class StellarSplitClient extends TypedEventEmitter<SplitClientEventMap> {
}

/**
* 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<NetworkEnvironment, NetworkEnvironment.CUSTOM>,
): Promise<void>;
public switchNetwork(
env: NetworkEnvironment.CUSTOM,
preset: NetworkPreset,
): Promise<void>;
public switchNetwork(preset: NetworkPreset): Promise<void>;
public async switchNetwork(
envOrPreset: NetworkEnvironment | NetworkPreset,
customPreset?: NetworkPreset,
): Promise<void> {
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<NetworkEnvironment, NetworkEnvironment.CUSTOM>
];
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);
}
}

// ---------------------------------------------------------------------------
Expand Down
37 changes: 37 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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<NetworkEnvironment, NetworkEnvironment.CUSTOM>,
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",
},
};
19 changes: 19 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading