diff --git a/src/adapters/chains/solana/adapter.ts b/src/adapters/chains/solana/adapter.ts index f777b4a..18e8729 100644 --- a/src/adapters/chains/solana/adapter.ts +++ b/src/adapters/chains/solana/adapter.ts @@ -8,7 +8,7 @@ import { fetchOutgoingMessage, type OutgoingMessage, } from "../../../clients/ts/src/bridge"; -import { validateRpcUrl } from "../../../core/validation"; +import { validateRpcUrl, validateWssUrl } from "../../../core/validation"; import type { SolanaAdapterConfig, SolanaChainAdapter } from "./types"; /** @@ -30,6 +30,9 @@ export function makeSolanaAdapter( config: SolanaAdapterConfig, ): SolanaChainAdapter { validateRpcUrl(config.rpcUrl); + if (config.wssUrl !== undefined) { + validateWssUrl(config.wssUrl); + } const payer = config.payer; const chain = config.chain ?? solanaMainnet; @@ -39,6 +42,7 @@ export function makeSolanaAdapter( kind: "solana", chain, rpcUrl: config.rpcUrl, + wssUrl: config.wssUrl, payer, async ping() { await rpc.getLatestBlockhash().send(); diff --git a/src/adapters/chains/solana/types.ts b/src/adapters/chains/solana/types.ts index 1c7f4b7..3d8e25a 100644 --- a/src/adapters/chains/solana/types.ts +++ b/src/adapters/chains/solana/types.ts @@ -8,6 +8,8 @@ import type { ChainAdapter, ChainRef } from "../../../core/types"; export interface SolanaAdapterConfig { rpcUrl: string; + /** Optional WebSocket URL for RPC subscriptions. If not provided, derived from `rpcUrl`. */ + wssUrl?: string; payer: KeyPairSigner; /** Optional label for chain ref. */ chain?: ChainRef; @@ -17,6 +19,7 @@ export interface SolanaChainAdapter extends ChainAdapter { readonly chain: ChainRef; readonly kind: "solana"; readonly rpcUrl: string; + readonly wssUrl?: string; readonly payer: KeyPairSigner; fetchOutgoingMessage( diff --git a/src/core/protocol/engines/solana-engine.ts b/src/core/protocol/engines/solana-engine.ts index 2618a8e..0ad8ee8 100644 --- a/src/core/protocol/engines/solana-engine.ts +++ b/src/core/protocol/engines/solana-engine.ts @@ -102,6 +102,8 @@ const DEFAULT_RELAY_GAS_LIMIT = 200_000n; interface SolanaEngineConfig { rpcUrl: string; + /** Optional WebSocket URL for RPC subscriptions. If not provided, derived from `rpcUrl`. */ + wssUrl?: string; payer: KeyPairSigner; bridgeProgram: SolAddress; relayerProgram: SolAddress; @@ -276,9 +278,14 @@ export class SolanaEngine { this.logger = opts.logger ?? NOOP_LOGGER; this.rpc = createSolanaRpc(this.config.rpcUrl); - const url = new URL(this.config.rpcUrl); - const wsScheme = url.protocol === "http:" ? "ws" : "wss"; - const wssUrl = `${wsScheme}://${url.host}${url.pathname}${url.search}`; + let wssUrl: string; + if (this.config.wssUrl !== undefined) { + wssUrl = this.config.wssUrl; + } else { + const url = new URL(this.config.rpcUrl); + const wsScheme = url.protocol === "http:" ? "ws" : "wss"; + wssUrl = `${wsScheme}://${url.host}${url.pathname}${url.search}`; + } const rpcSubscriptions = createSolanaRpcSubscriptions(wssUrl); this.sendAndConfirmTx = sendAndConfirmTransactionFactory({ rpc: this.rpc, diff --git a/src/core/protocol/routes/base-to-svm.ts b/src/core/protocol/routes/base-to-svm.ts index 5cd660d..5892cb5 100644 --- a/src/core/protocol/routes/base-to-svm.ts +++ b/src/core/protocol/routes/base-to-svm.ts @@ -125,6 +125,7 @@ export class BaseToSvmRouteAdapter implements RouteAdapter { this.solanaEngine = new SolanaEngine({ config: { rpcUrl: this.solana.rpcUrl, + wssUrl: this.solana.wssUrl, payer: this.solana.payer, bridgeProgram: this.solanaDeployment.bridgeProgram, relayerProgram: this.solanaDeployment.relayerProgram, diff --git a/src/core/protocol/routes/svm-to-base.ts b/src/core/protocol/routes/svm-to-base.ts index df6060f..3f851b6 100644 --- a/src/core/protocol/routes/svm-to-base.ts +++ b/src/core/protocol/routes/svm-to-base.ts @@ -141,6 +141,7 @@ export class SvmToBaseRouteAdapter implements RouteAdapter { this.solanaEngine = new SolanaEngine({ config: { rpcUrl: this.solana.rpcUrl, + wssUrl: this.solana.wssUrl, payer: this.solana.payer, bridgeProgram: this.solanaDeployment.bridgeProgram, relayerProgram: this.solanaDeployment.relayerProgram, diff --git a/src/core/validation.ts b/src/core/validation.ts index 299443f..f58e6dd 100644 --- a/src/core/validation.ts +++ b/src/core/validation.ts @@ -16,29 +16,41 @@ import { const MAX_TRANSFER_AMOUNT = 2n ** 64n - 1n; -export function validateRpcUrl(rpcUrl: string): void { - if (rpcUrl.trim() === "") { +function validateUrlScheme( + url: string, + allowedProtocols: string[], + label: string, +): void { + if (url.trim() === "") { throw new BridgeValidationError( - `Invalid RPC URL: expected a non-empty HTTP(S) URL, got "${truncate(String(rpcUrl))}"`, + `Invalid ${label}: expected a non-empty URL, got "${truncate(String(url))}"`, ); } let parsed: URL; try { - parsed = new URL(rpcUrl); + parsed = new URL(url); } catch { throw new BridgeValidationError( - `Invalid RPC URL: not a valid URL, got "${truncate(rpcUrl)}"`, + `Invalid ${label}: not a valid URL, got "${truncate(url)}"`, ); } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + if (!allowedProtocols.includes(parsed.protocol)) { throw new BridgeValidationError( - `Invalid RPC URL: expected http: or https: scheme, got "${parsed.protocol}" in "${truncate(rpcUrl)}"`, + `Invalid ${label}: expected ${allowedProtocols.join(" or ")} scheme, got "${parsed.protocol}" in "${truncate(url)}"`, ); } } +export function validateRpcUrl(rpcUrl: string): void { + validateUrlScheme(rpcUrl, ["http:", "https:"], "RPC URL"); +} + +export function validateWssUrl(wssUrl: string): void { + validateUrlScheme(wssUrl, ["ws:", "wss:"], "WebSocket URL"); +} + export function validateAction(action: BridgeAction, route: BridgeRoute): void { if (action.kind === "transfer") { validateAmount(action.amount); diff --git a/tests/adapter-config.test.ts b/tests/adapter-config.test.ts index 3684268..3c92131 100644 --- a/tests/adapter-config.test.ts +++ b/tests/adapter-config.test.ts @@ -4,7 +4,7 @@ import { makeEvmAdapter } from "../src/adapters/chains/evm/adapter"; import type { EvmAdapterConfig } from "../src/adapters/chains/evm/types"; import { makeSolanaAdapter } from "../src/adapters/chains/solana/adapter"; import { BridgeValidationError } from "../src/core/errors"; -import { validateRpcUrl } from "../src/core/validation"; +import { validateRpcUrl, validateWssUrl } from "../src/core/validation"; /** Minimal mock that satisfies the KeyPairSigner interface shape at runtime. */ const mockPayer = { @@ -85,6 +85,69 @@ describe("validateRpcUrl", () => { }); }); +describe("validateWssUrl", () => { + describe("rejects invalid URLs", () => { + test("throws for empty string", () => { + expect(() => validateWssUrl("")).toThrow("Invalid WebSocket URL"); + }); + + test("throws for whitespace-only string", () => { + expect(() => validateWssUrl(" ")).toThrow("Invalid WebSocket URL"); + }); + + test("throws for non-URL string", () => { + expect(() => validateWssUrl("not-a-url")).toThrow( + "Invalid WebSocket URL", + ); + }); + + test("throws for http:// scheme", () => { + expect(() => validateWssUrl("http://localhost:8899")).toThrow( + "expected ws: or wss: scheme", + ); + }); + + test("throws for https:// scheme", () => { + expect(() => validateWssUrl("https://api.example.com")).toThrow( + "expected ws: or wss: scheme", + ); + }); + }); + + describe("accepts valid URLs", () => { + test("accepts wss:// URL", () => { + expect(() => + validateWssUrl("wss://api.mainnet-beta.solana.com"), + ).not.toThrow(); + }); + + test("accepts ws:// URL (localhost dev)", () => { + expect(() => validateWssUrl("ws://localhost:8900")).not.toThrow(); + }); + + test("accepts wss:// URL with path", () => { + expect(() => + validateWssUrl("wss://rpc.example.com/v1/mainnet"), + ).not.toThrow(); + }); + }); + + describe("error properties", () => { + test("thrown error is a BridgeValidationError", () => { + let error: BridgeValidationError | undefined; + try { + validateWssUrl(""); + } catch (e) { + error = e as BridgeValidationError; + } + expect(error).toBeInstanceOf(BridgeValidationError); + expect(error?.code).toBe("VALIDATION"); + expect(error?.outcome).toBe("user_fix"); + expect(error?.stage).toBe("initiate"); + }); + }); +}); + describe("makeSolanaAdapter config validation", () => { test("throws for empty rpcUrl", () => { expect(() => makeSolanaAdapter({ rpcUrl: "", payer: mockPayer })).toThrow( @@ -121,6 +184,81 @@ describe("makeSolanaAdapter config validation", () => { makeSolanaAdapter({ rpcUrl: VALID_RPC, payer: mockPayer }), ).not.toThrow(); }); + + describe("wssUrl validation", () => { + test("accepts config without wssUrl (optional)", () => { + expect(() => + makeSolanaAdapter({ rpcUrl: VALID_RPC, payer: mockPayer }), + ).not.toThrow(); + }); + + test("accepts valid wss:// URL", () => { + expect(() => + makeSolanaAdapter({ + rpcUrl: VALID_RPC, + wssUrl: "wss://api.mainnet-beta.solana.com", + payer: mockPayer, + }), + ).not.toThrow(); + }); + + test("accepts valid ws:// URL", () => { + expect(() => + makeSolanaAdapter({ + rpcUrl: "http://localhost:8899", + wssUrl: "ws://localhost:8900", + payer: mockPayer, + }), + ).not.toThrow(); + }); + + test("throws for http:// wssUrl", () => { + expect(() => + makeSolanaAdapter({ + rpcUrl: VALID_RPC, + wssUrl: "http://localhost:8899", + payer: mockPayer, + }), + ).toThrow("expected ws: or wss: scheme"); + }); + + test("throws for empty wssUrl", () => { + expect(() => + makeSolanaAdapter({ + rpcUrl: VALID_RPC, + wssUrl: "", + payer: mockPayer, + }), + ).toThrow("Invalid WebSocket URL"); + }); + + test("throws for invalid wssUrl", () => { + expect(() => + makeSolanaAdapter({ + rpcUrl: VALID_RPC, + wssUrl: "not-a-url", + payer: mockPayer, + }), + ).toThrow("Invalid WebSocket URL"); + }); + + test("wssUrl is available on the returned adapter", () => { + const adapter = makeSolanaAdapter({ + rpcUrl: VALID_RPC, + wssUrl: "wss://custom.rpc.example.com", + payer: mockPayer, + }); + expect(adapter.wssUrl).toBe("wss://custom.rpc.example.com"); + }); + + test("wssUrl is undefined when not provided", () => { + const adapter = makeSolanaAdapter({ + rpcUrl: VALID_RPC, + payer: mockPayer, + }); + expect(adapter.wssUrl).toBeUndefined(); + }); + }); }); describe("makeEvmAdapter config validation", () => {