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
6 changes: 5 additions & 1 deletion src/adapters/chains/solana/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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;
Expand All @@ -39,6 +42,7 @@ export function makeSolanaAdapter(
kind: "solana",
chain,
rpcUrl: config.rpcUrl,
wssUrl: config.wssUrl,
payer,
async ping() {
await rpc.getLatestBlockhash().send();
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/chains/solana/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down
13 changes: 10 additions & 3 deletions src/core/protocol/engines/solana-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/core/protocol/routes/base-to-svm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/core/protocol/routes/svm-to-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 19 additions & 7 deletions src/core/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
140 changes: 139 additions & 1 deletion tests/adapter-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading