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
27 changes: 26 additions & 1 deletion src/adapters/chains/evm/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import {
type Hash,
type Hex,
http,
isHex,
type PublicClient,
type WalletClient,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { BridgeValidationError } from "../../../core/errors";
import type { ChainRef } from "../../../core/types";
import { validateRpcUrl } from "../../../core/validation";
import type {
BridgeEvmChainRef,
EvmAdapterConfig,
Expand Down Expand Up @@ -53,6 +56,29 @@ function resolveChain(config: EvmAdapterConfig): {
}

export function makeEvmAdapter(config: EvmAdapterConfig): EvmChainAdapter {
validateRpcUrl(config.rpcUrl);

if (config.chain == null) {
if (
config.chainId == null ||
!Number.isInteger(config.chainId) ||
config.chainId < 1
) {
throw new BridgeValidationError(
`Invalid EVM adapter config: chainId must be a positive integer, got ${String(config.chainId)}`,
);
}
}

const wallet = config.wallet ?? { type: "none" as const };
if (wallet.type === "privateKey") {
if (!isHex(wallet.key) || wallet.key.length !== 66) {
throw new BridgeValidationError(
"Invalid EVM adapter config: wallet private key must be a 0x-prefixed 64-character hex string",
);
}
}

const { chainId, viemChain } = resolveChain(config);
const chain: ChainRef = { id: `eip155:${chainId}` };

Expand All @@ -66,7 +92,6 @@ export function makeEvmAdapter(config: EvmAdapterConfig): EvmChainAdapter {
let walletClient: WalletClient | undefined;
let privateKey: Hex | undefined;

const wallet = config.wallet ?? { type: "none" as const };
if (wallet.type === "privateKey") {
const account = privateKeyToAccount(wallet.key);
walletClient = createWalletClient({
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/chains/solana/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
fetchOutgoingMessage,
type OutgoingMessage,
} from "../../../clients/ts/src/bridge";
import { validateRpcUrl } from "../../../core/validation";
import type { SolanaAdapterConfig, SolanaChainAdapter } from "./types";

/**
Expand All @@ -28,6 +29,8 @@ import type { SolanaAdapterConfig, SolanaChainAdapter } from "./types";
export function makeSolanaAdapter(
config: SolanaAdapterConfig,
): SolanaChainAdapter {
validateRpcUrl(config.rpcUrl);

const payer = config.payer;
const chain = config.chain ?? solanaMainnet;
const rpc = createSolanaRpc(config.rpcUrl);
Expand Down
23 changes: 23 additions & 0 deletions src/core/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,29 @@ import {

const MAX_TRANSFER_AMOUNT = 2n ** 64n - 1n;

export function validateRpcUrl(rpcUrl: string): void {
if (rpcUrl.trim() === "") {
throw new BridgeValidationError(
`Invalid RPC URL: expected a non-empty HTTP(S) URL, got "${truncate(String(rpcUrl))}"`,
);
}

let parsed: URL;
try {
parsed = new URL(rpcUrl);
} catch {
throw new BridgeValidationError(
`Invalid RPC URL: not a valid URL, got "${truncate(rpcUrl)}"`,
);
}

if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new BridgeValidationError(
`Invalid RPC URL: expected http: or https: scheme, got "${parsed.protocol}" in "${truncate(rpcUrl)}"`,
);
}
}

export function validateAction(action: BridgeAction, route: BridgeRoute): void {
if (action.kind === "transfer") {
validateAmount(action.amount);
Expand Down
251 changes: 251 additions & 0 deletions tests/adapter-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { describe, expect, test } from "bun:test";
import type { KeyPairSigner } from "@solana/kit";
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";

/** Minimal mock that satisfies the KeyPairSigner interface shape at runtime. */
const mockPayer = {
address: "11111111111111111111111111111111",
keyPair: {},
} as unknown as KeyPairSigner;

const VALID_RPC = "https://api.mainnet-beta.solana.com";

describe("validateRpcUrl", () => {
describe("rejects invalid URLs", () => {
test("throws for empty string", () => {
expect(() => validateRpcUrl("")).toThrow("Invalid RPC URL");
});

test("throws for whitespace-only string", () => {
expect(() => validateRpcUrl(" ")).toThrow("Invalid RPC URL");
});

test("throws for non-URL string", () => {
expect(() => validateRpcUrl("not-a-url")).toThrow("Invalid RPC URL");
});

test("throws for ws:// scheme", () => {
expect(() => validateRpcUrl("ws://localhost:8900")).toThrow(
"expected http: or https: scheme",
);
});

test("throws for wss:// scheme", () => {
expect(() => validateRpcUrl("wss://api.example.com")).toThrow(
"expected http: or https: scheme",
);
});

test("throws for ftp:// scheme", () => {
expect(() => validateRpcUrl("ftp://files.example.com")).toThrow(
"expected http: or https: scheme",
);
});
});

describe("accepts valid URLs", () => {
test("accepts https:// URL", () => {
expect(() =>
validateRpcUrl("https://api.mainnet-beta.solana.com"),
).not.toThrow();
});

test("accepts http:// URL (localhost dev)", () => {
expect(() => validateRpcUrl("http://localhost:8899")).not.toThrow();
});

test("accepts http:// URL with path", () => {
expect(() =>
validateRpcUrl("https://rpc.example.com/v1/mainnet"),
).not.toThrow();
});
});

describe("error properties", () => {
test("thrown error is a BridgeValidationError with expected fields", () => {
let error: BridgeValidationError | undefined;
try {
validateRpcUrl("");
} 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");
});

test("error message includes the invalid value", () => {
expect(() => validateRpcUrl("bad-url")).toThrow('got "bad-url"');
});
});
});

describe("makeSolanaAdapter config validation", () => {
test("throws for empty rpcUrl", () => {
expect(() => makeSolanaAdapter({ rpcUrl: "", payer: mockPayer })).toThrow(
"Invalid RPC URL",
);
});

test("throws for invalid rpcUrl", () => {
expect(() =>
makeSolanaAdapter({ rpcUrl: "not-a-url", payer: mockPayer }),
).toThrow("Invalid RPC URL");
});

test("throws for ws:// rpcUrl", () => {
expect(() =>
makeSolanaAdapter({ rpcUrl: "ws://localhost:8900", payer: mockPayer }),
).toThrow("expected http: or https: scheme");
});

test("all thrown errors are BridgeValidationError", () => {
let error: BridgeValidationError | undefined;
try {
makeSolanaAdapter({ rpcUrl: "", payer: mockPayer });
} catch (e) {
error = e as BridgeValidationError;
}
expect(error).toBeInstanceOf(BridgeValidationError);
expect(error?.code).toBe("VALIDATION");
expect(error?.outcome).toBe("user_fix");
});

test("accepts valid config", () => {
expect(() =>
makeSolanaAdapter({ rpcUrl: VALID_RPC, payer: mockPayer }),
).not.toThrow();
});
});

describe("makeEvmAdapter config validation", () => {
const validConfig: EvmAdapterConfig = {
rpcUrl: "https://mainnet.base.org",
chainId: 8453,
};

describe("rpcUrl validation", () => {
test("throws for empty rpcUrl", () => {
expect(() => makeEvmAdapter({ ...validConfig, rpcUrl: "" })).toThrow(
"Invalid RPC URL",
);
});

test("throws for invalid rpcUrl", () => {
expect(() =>
makeEvmAdapter({ ...validConfig, rpcUrl: "not-a-url" }),
).toThrow("Invalid RPC URL");
});

test("throws for ws:// rpcUrl", () => {
expect(() =>
makeEvmAdapter({ ...validConfig, rpcUrl: "ws://localhost:8545" }),
).toThrow("expected http: or https: scheme");
});
});

describe("chainId validation", () => {
test("throws for chainId = 0", () => {
expect(() =>
makeEvmAdapter({ rpcUrl: validConfig.rpcUrl, chainId: 0 }),
).toThrow("chainId must be a positive integer");
});

test("throws for negative chainId", () => {
expect(() =>
makeEvmAdapter({ rpcUrl: validConfig.rpcUrl, chainId: -1 }),
).toThrow("chainId must be a positive integer");
});

test("throws for non-integer chainId", () => {
expect(() =>
makeEvmAdapter({ rpcUrl: validConfig.rpcUrl, chainId: 1.5 }),
).toThrow("chainId must be a positive integer");
});

test("does not validate chainId when chain object is provided", () => {
// When a chain object is provided, chainId comes from the chain
expect(() =>
makeEvmAdapter({
rpcUrl: validConfig.rpcUrl,
chain: {
id: 8453,
name: "Base",
nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 },
rpcUrls: { default: { http: [""] } },
},
}),
).not.toThrow();
});
});

describe("wallet private key validation", () => {
test("throws for invalid private key (not hex)", () => {
expect(() =>
makeEvmAdapter({
...validConfig,
wallet: {
type: "privateKey",
key: "not-a-key" as `0x${string}`,
},
}),
).toThrow("wallet private key must be a 0x-prefixed 64-character hex");
});

test("throws for too-short private key", () => {
expect(() =>
makeEvmAdapter({
...validConfig,
wallet: {
type: "privateKey",
key: "0x1234",
},
}),
).toThrow("wallet private key must be a 0x-prefixed 64-character hex");
});

test("accepts valid private key", () => {
expect(() =>
makeEvmAdapter({
...validConfig,
wallet: {
type: "privateKey",
key: `0x${"a".repeat(64)}` as `0x${string}`,
},
}),
).not.toThrow();
});

test("does not validate key when wallet type is none", () => {
expect(() =>
makeEvmAdapter({
...validConfig,
wallet: { type: "none" },
}),
).not.toThrow();
});
});

describe("error properties", () => {
test("all thrown errors are BridgeValidationError", () => {
let error: BridgeValidationError | undefined;
try {
makeEvmAdapter({ ...validConfig, rpcUrl: "" });
} catch (e) {
error = e as BridgeValidationError;
}
expect(error).toBeInstanceOf(BridgeValidationError);
expect(error?.code).toBe("VALIDATION");
expect(error?.outcome).toBe("user_fix");
});
});

test("accepts valid config with chainId", () => {
expect(() => makeEvmAdapter(validConfig)).not.toThrow();
});
});
Loading