Skip to content
Merged
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
67 changes: 64 additions & 3 deletions src/client/createSorokitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,8 @@ export interface SorokitClientConfig {
horizonUrl?: string;
/** Override the default Soroban RPC URL */
rpcUrl?: string;
/** Override the default network passphrase */
networkPassphrase?: string;
/** Ordered Horizon endpoints: primary first, followed by backup endpoints. */
horizonEndpoints?: string[];
/** Ordered Soroban RPC endpoints: primary first, followed by backup endpoints. */
Expand Down Expand Up @@ -1077,7 +1079,13 @@ export function validateClientConfig(
}

const validNetworks = ["mainnet", "testnet", "futurenet"];
if (!config.network || !validNetworks.includes(config.network)) {
if (!config.network) {
return err(
SorokitErrorCode.INVALID_NETWORK,
"Missing required config: network. Must be one of: mainnet, testnet, futurenet",
);
}
if (!validNetworks.includes(config.network)) {
return err(
SorokitErrorCode.INVALID_NETWORK,
`Invalid network type: ${String(config.network)}. Must be one of: mainnet, testnet, futurenet`,
Expand Down Expand Up @@ -1105,6 +1113,18 @@ export function validateClientConfig(
}
}

if (config.networkPassphrase !== undefined) {
if (
typeof config.networkPassphrase !== "string" ||
config.networkPassphrase.trim().length === 0
) {
return err(
SorokitErrorCode.INVALID_CONFIG,
`Invalid networkPassphrase: ${String(config.networkPassphrase)}`,
);
}
}

for (const [name, endpoints] of [
["horizonEndpoints", config.horizonEndpoints],
["rpcEndpoints", config.rpcEndpoints],
Expand Down Expand Up @@ -1299,8 +1319,49 @@ export function createSorokitClient(

if (networkResult.status === "error") return networkResult;

const networkConfig = networkResult.data;
const { horizonUrl, rpcUrl, networkPassphrase } = networkConfig;
const baseNetworkConfig = networkResult.data;
const horizonUrl =
config.horizonEndpoints?.[0] ?? config.horizonUrl ?? baseNetworkConfig.horizonUrl;
const rpcUrl =
config.rpcEndpoints?.[0] ?? config.rpcUrl ?? baseNetworkConfig.rpcUrl;
const networkPassphrase =
config.networkPassphrase ?? baseNetworkConfig.networkPassphrase;

if (
!horizonUrl ||
typeof horizonUrl !== "string" ||
!isValidUrlString(horizonUrl)
) {
return err(
SorokitErrorCode.INVALID_CONFIG,
`Invalid horizonUrl: ${String(horizonUrl)}`,
);
}

if (!rpcUrl || typeof rpcUrl !== "string" || !isValidUrlString(rpcUrl)) {
return err(
SorokitErrorCode.INVALID_CONFIG,
`Invalid rpcUrl: ${String(rpcUrl)}`,
);
}

if (
!networkPassphrase ||
typeof networkPassphrase !== "string" ||
networkPassphrase.trim().length === 0
) {
return err(
SorokitErrorCode.INVALID_CONFIG,
`Invalid networkPassphrase: ${String(networkPassphrase)}`,
);
}

const networkConfig: ResolvedNetworkConfig = {
...baseNetworkConfig,
horizonUrl,
rpcUrl,
networkPassphrase,
};
if (config.horizonEndpoints) {
configureEndpointFailover(
config.horizonEndpoints[0]!,
Expand Down
150 changes: 150 additions & 0 deletions src/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,156 @@ describe("createSorokitClient", () => {
});
});

describe("createSorokitClient required config validation (#755)", () => {
it("returns INVALID_CONFIG when configuration object is missing (null or undefined)", () => {
// @ts-expect-error — intentionally passing invalid config
const resNull = createSorokitClient(null);
expect(resNull.status).toBe("error");
if (resNull.status === "error") {
expect(resNull.error.code).toBe(SorokitErrorCode.INVALID_CONFIG);
expect(resNull.error.message).toContain("Configuration must be an object");
}

// @ts-expect-error — intentionally passing invalid config
const resUndef = createSorokitClient(undefined);
expect(resUndef.status).toBe("error");
if (resUndef.status === "error") {
expect(resUndef.error.code).toBe(SorokitErrorCode.INVALID_CONFIG);
expect(resUndef.error.message).toContain("Configuration must be an object");
}
});

it("returns error when network is missing or invalid", () => {
// @ts-expect-error — missing required network
const resMissing = createSorokitClient({});
expect(resMissing.status).toBe("error");
if (resMissing.status === "error") {
expect(resMissing.error.code).toBe(SorokitErrorCode.INVALID_NETWORK);
expect(resMissing.error.message).toContain("network");
}

// @ts-expect-error — invalid network string
const resInvalid = createSorokitClient({ network: "invalid-net" });
expect(resInvalid.status).toBe("error");
if (resInvalid.status === "error") {
expect(resInvalid.error.code).toBe(SorokitErrorCode.INVALID_NETWORK);
expect(resInvalid.error.message).toContain("Invalid network type: invalid-net");
}
});

it("returns INVALID_CONFIG when horizonUrl is invalid", () => {
const resEmpty = createSorokitClient({
network: "testnet",
horizonUrl: "",
});
expect(resEmpty.status).toBe("error");
if (resEmpty.status === "error") {
expect(resEmpty.error.code).toBe(SorokitErrorCode.INVALID_CONFIG);
expect(resEmpty.error.message).toContain("horizonUrl");
}

const resMalformed = createSorokitClient({
network: "testnet",
horizonUrl: "ftp://not-http-url",
});
expect(resMalformed.status).toBe("error");
if (resMalformed.status === "error") {
expect(resMalformed.error.code).toBe(SorokitErrorCode.INVALID_CONFIG);
expect(resMalformed.error.message).toContain("horizonUrl");
}

// @ts-expect-error — non-string type
const resType = createSorokitClient({
network: "testnet",
horizonUrl: 12345,
});
expect(resType.status).toBe("error");
if (resType.status === "error") {
expect(resType.error.code).toBe(SorokitErrorCode.INVALID_CONFIG);
expect(resType.error.message).toContain("horizonUrl");
}
});

it("returns INVALID_CONFIG when rpcUrl is invalid", () => {
const resEmpty = createSorokitClient({
network: "testnet",
rpcUrl: "",
});
expect(resEmpty.status).toBe("error");
if (resEmpty.status === "error") {
expect(resEmpty.error.code).toBe(SorokitErrorCode.INVALID_CONFIG);
expect(resEmpty.error.message).toContain("rpcUrl");
}

const resMalformed = createSorokitClient({
network: "testnet",
rpcUrl: "not-a-valid-url",
});
expect(resMalformed.status).toBe("error");
if (resMalformed.status === "error") {
expect(resMalformed.error.code).toBe(SorokitErrorCode.INVALID_CONFIG);
expect(resMalformed.error.message).toContain("rpcUrl");
}

// @ts-expect-error — non-string type
const resType = createSorokitClient({
network: "testnet",
rpcUrl: true,
});
expect(resType.status).toBe("error");
if (resType.status === "error") {
expect(resType.error.code).toBe(SorokitErrorCode.INVALID_CONFIG);
expect(resType.error.message).toContain("rpcUrl");
}
});

it("returns INVALID_CONFIG when networkPassphrase is empty or invalid", () => {
const resEmpty = createSorokitClient({
network: "testnet",
networkPassphrase: "",
});
expect(resEmpty.status).toBe("error");
if (resEmpty.status === "error") {
expect(resEmpty.error.code).toBe(SorokitErrorCode.INVALID_CONFIG);
expect(resEmpty.error.message).toContain("networkPassphrase");
}

const resWhitespace = createSorokitClient({
network: "testnet",
networkPassphrase: " ",
});
expect(resWhitespace.status).toBe("error");
if (resWhitespace.status === "error") {
expect(resWhitespace.error.code).toBe(SorokitErrorCode.INVALID_CONFIG);
expect(resWhitespace.error.message).toContain("networkPassphrase");
}

// @ts-expect-error — non-string type
const resType = createSorokitClient({
network: "testnet",
networkPassphrase: 12345,
});
expect(resType.status).toBe("error");
if (resType.status === "error") {
expect(resType.error.code).toBe(SorokitErrorCode.INVALID_CONFIG);
expect(resType.error.message).toContain("networkPassphrase");
}
});

it("allows valid networkPassphrase override and binds it to client", () => {
const customPassphrase = "Custom Private Network ; 2026";
const result = createSorokitClient({
network: "testnet",
networkPassphrase: customPassphrase,
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.data.networkConfig.networkPassphrase).toBe(customPassphrase);
expect(result.data.network.getConfig().networkPassphrase).toBe(customPassphrase);
}
});
});

it("exposes version property matching package version", () => {
const result = createSorokitClient({ network: "testnet" });
expect(result.status).toBe("ok");
Expand Down
Loading