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
13 changes: 2 additions & 11 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import type {
StatusOptions,
TransferRequestInput,
} from "./types";
import { validateDestinationCall } from "./utils";
import { validateAction } from "./validation";

export interface BridgeClientConfig {
Expand Down Expand Up @@ -100,11 +99,6 @@ class DefaultBridgeClient implements BridgeClient {
}

async transfer(req: TransferRequestInput): Promise<BridgeOperation> {
// Validate call matches destination chain if present
if (req.call) {
validateDestinationCall(req.call, req.route);
}

const bridgeReq: BridgeRequest = {
route: req.route,
action: {
Expand All @@ -122,9 +116,6 @@ class DefaultBridgeClient implements BridgeClient {
}

async call(req: CallRequestInput): Promise<BridgeOperation> {
// Validate call matches destination chain
validateDestinationCall(req.call, req.route);

const bridgeReq: BridgeRequest = {
route: req.route,
action: { kind: "call", call: req.call },
Expand All @@ -136,7 +127,7 @@ class DefaultBridgeClient implements BridgeClient {
}

async request(req: BridgeRequest): Promise<BridgeOperation> {
validateAction(req.action);
validateAction(req.action, req.route);
const adapter = await this.getRouteAdapter(req.route);
this.logger.debug(
`bridge.request: initiating ${req.route.sourceChain} -> ${req.route.destinationChain}`,
Expand All @@ -145,7 +136,7 @@ class DefaultBridgeClient implements BridgeClient {
}

async quote(req: QuoteRequest): Promise<Quote> {
validateAction(req.action);
validateAction(req.action, req.route);
const adapter = await this.getRouteAdapter(req.route);
this.logger.debug(
`bridge.quote: estimating ${req.route.sourceChain} -> ${req.route.destinationChain}`,
Expand Down
2 changes: 1 addition & 1 deletion src/core/protocol/engines/solana-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,7 +1112,7 @@ export class SolanaEngine {
: call.data;

return {
ty: call.ty ?? CallType.Call,
ty: (call.ty as CallType | undefined) ?? CallType.Call,
to: toBytes(call.to),
value: call.value,
data: Buffer.from(callData, "hex"),
Expand Down
2 changes: 1 addition & 1 deletion src/core/protocol/routes/svm-to-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ export class SvmToBaseRouteAdapter implements RouteAdapter {
// 1. Initialize the call buffer
const { bufferAddress, signature: initSig } =
await this.solanaEngine.initializeCallBuffer({
callType: call.ty ?? CallType.Call,
callType: (call.ty as CallType | undefined) ?? CallType.Call,
to: toBytes(call.to),
value: call.value,
initialData: firstChunk,
Expand Down
22 changes: 20 additions & 2 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,30 @@ export type AssetRef =
| { kind: "token"; address: string } // mint for Solana, ERC20 for EVM
| { kind: "wrapped"; address: string }; // protocol-specific wrapped token id

/**
* EVM call type matching the on-chain `CallType` enum.
* - `Call` (0): Regular external call (`address.call{value}(data)`)
* - `DelegateCall` (1): Delegate call (`address.delegatecall(data)`); value must be 0
* - `Create` (2): Deploy via `CREATE` opcode; `to` must be zero address
* - `Create2` (3): Deploy via `CREATE2` opcode; `to` must be zero address;
* `data` must be `abi.encode(bytes32 salt, bytes creationCode)`
*/
export enum EvmCallType {
Call = 0,
DelegateCall = 1,
Create = 2,
Create2 = 3,
}

export interface EvmCall {
to: `0x${string}`;
value: bigint;
data: `0x${string}`;
/** Optional protocol-specific call type. */
ty?: number;
/**
* Call type determining how the call is executed on-chain.
* Defaults to `EvmCallType.Call` (0) when omitted.
*/
ty?: EvmCallType;
}

/**
Expand Down
33 changes: 1 addition & 32 deletions src/core/utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import type {
BridgeRoute,
ChainId,
DestinationCall,
EvmCall,
SolanaCall,
} from "./types";
import type { ChainId, DestinationCall, EvmCall, SolanaCall } from "./types";

/**
* Type guard for SolanaCall destination.
Expand All @@ -30,28 +24,3 @@ export function isEvmDestinationCall(
export function isSolanaChainId(chainId: ChainId): boolean {
return chainId.startsWith("solana:");
}

/**
* Validate that a DestinationCall matches the route's destination chain.
*
* @throws Error if call type doesn't match destination chain
*/
export function validateDestinationCall(
call: DestinationCall,
route: BridgeRoute,
): void {
const isSvmDestination = isSolanaChainId(route.destinationChain);

if (isSvmDestination && !isSolanaDestinationCall(call)) {
throw new Error(
`Call type mismatch: route destination is Solana but call kind is "${call.kind}". ` +
`Use { kind: "solana", call: SolanaCall } for Base -> SVM routes.`,
);
}
if (!isSvmDestination && !isEvmDestinationCall(call)) {
throw new Error(
`Call type mismatch: route destination is EVM but call kind is "${call.kind}". ` +
`Use { kind: "evm", call: EvmCall } for SVM -> Base routes.`,
);
}
}
167 changes: 164 additions & 3 deletions src/core/validation.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import { isAddress as isSolanaAddress } from "@solana/kit";
import { isAddress as isEvmAddress, isHex, zeroAddress } from "viem";
import { BridgeValidationError } from "./errors";
import type { BridgeAction } from "./types";
import {
type BridgeAction,
type BridgeRoute,
type DestinationCall,
type EvmCall,
EvmCallType,
} from "./types";
import {
isEvmDestinationCall,
isSolanaChainId,
isSolanaDestinationCall,
} from "./utils";

/** Maximum uint64 value. */
const MAX_TRANSFER_AMOUNT = 2n ** 64n - 1n;

export function validateAction(action: BridgeAction): void {
export function validateAction(action: BridgeAction, route: BridgeRoute): void {
if (action.kind === "transfer") {
validateAmount(action.amount);
if (action.call) {
validateDestinationCallFields(action.call, route);
}
validateRecipientAddress(action.recipient, route);
} else {
validateDestinationCallFields(action.call, route);
}
}

Expand All @@ -20,3 +38,146 @@ export function validateAmount(amount: bigint): void {
);
}
}

/** Does NOT enforce EIP-55 mixed-case checksum. */
export function validateEvmAddress(address: string): void {
if (!isEvmAddress(address, { strict: false })) {
throw new BridgeValidationError(
`Invalid EVM address: expected 0x-prefixed 42-character hex string, got "${truncate(address)}"`,
);
}
}

export function validateSolanaAddress(address: string): void {
if (!isSolanaAddress(address)) {
throw new BridgeValidationError(
`Invalid Solana address: expected base58-encoded 32-byte public key, got "${truncate(address)}"`,
);
}
}

export function validateEvmCallData(data: string): void {
if (!isHex(data)) {
throw new BridgeValidationError(
`Invalid EVM call data: expected 0x-prefixed hex string, got "${truncate(data)}"`,
);
}
}

/** Uint8Array values are always valid; hex strings must be well-formed. */
export function validateSolanaInstructionData(
data: Uint8Array | `0x${string}`,
): void {
if (typeof data === "string" && !isHex(data)) {
throw new BridgeValidationError(
`Invalid Solana instruction data: expected Uint8Array or 0x-prefixed hex string, got "${truncate(data)}"`,
);
}
}

export function validateEvmCallValue(value: bigint): void {
if (value < 0n) {
throw new BridgeValidationError("EVM call value must not be negative");
}
}

/**
* Enforce cross-field constraints required by the on-chain bridge contract:
* - `DelegateCall`: `value` must be 0.
* - `Create` / `Create2`: `to` must be the zero address.
*/
export function validateEvmCallType(call: EvmCall): void {
if (call.ty === undefined) {
return;
}

if (call.ty < EvmCallType.Call || call.ty > EvmCallType.Create2) {
throw new BridgeValidationError(
`Invalid EVM call type: expected 0 (Call), 1 (DelegateCall), 2 (Create), or 3 (Create2), got ${call.ty}`,
);
}

if (call.ty === EvmCallType.DelegateCall && call.value !== 0n) {
throw new BridgeValidationError(
"DelegateCall cannot have a non-zero value",
);
}

if (
(call.ty === EvmCallType.Create || call.ty === EvmCallType.Create2) &&
call.to !== zeroAddress
) {
throw new BridgeValidationError(
`${EvmCallType[call.ty]} requires the \`to\` address to be the zero address`,
);
}
}

/**
* Validate that a DestinationCall matches the route's destination chain.
*
* @throws BridgeValidationError if call type doesn't match destination chain
*/
export function validateDestinationCall(
call: DestinationCall,
route: BridgeRoute,
): void {
const isSvmDestination = isSolanaChainId(route.destinationChain);

if (isSvmDestination && !isSolanaDestinationCall(call)) {
throw new BridgeValidationError(
`Call type mismatch: route destination is Solana but call kind is "${call.kind}". ` +
`Use { kind: "solana", call: SolanaCall } for Base -> SVM routes.`,
{ route },
);
}
if (!isSvmDestination && !isEvmDestinationCall(call)) {
throw new BridgeValidationError(
`Call type mismatch: route destination is EVM but call kind is "${call.kind}". ` +
`Use { kind: "evm", call: EvmCall } for SVM -> Base routes.`,
{ route },
);
}
}

export function validateDestinationCallFields(
call: DestinationCall,
route: BridgeRoute,
): void {
validateDestinationCall(call, route);

if (call.kind === "evm") {
validateEvmAddress(call.call.to);
validateEvmCallData(call.call.data);
validateEvmCallValue(call.call.value);
validateEvmCallType(call.call);
} else {
if (call.call.instructions.length === 0) {
throw new BridgeValidationError(
"Solana call must include at least one instruction",
);
}
for (const ix of call.call.instructions) {
validateSolanaAddress(ix.programId);
for (const acct of ix.accounts) {
validateSolanaAddress(acct.pubkey);
}
validateSolanaInstructionData(ix.data);
}
}
}

export function validateRecipientAddress(
recipient: string,
route: BridgeRoute,
): void {
if (isSolanaChainId(route.destinationChain)) {
validateSolanaAddress(recipient);
} else {
validateEvmAddress(recipient);
}
}

function truncate(value: string, max = 48): string {
return value.length > max ? `${value.slice(0, max)}…` : value;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { createBridgeClient } from "./core/client";
export type { ActionableOutcome, BridgeErrorCode } from "./core/errors";
export { BridgeError } from "./core/errors";
export type * from "./core/types";
export { EvmCallType } from "./core/types";
17 changes: 16 additions & 1 deletion tests/solana-call.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { BridgeValidationError } from "../src/core/errors";
import type {
BridgeRoute,
DestinationCall,
Expand All @@ -9,8 +10,8 @@ import {
isEvmDestinationCall,
isSolanaChainId,
isSolanaDestinationCall,
validateDestinationCall,
} from "../src/core/utils";
import { validateDestinationCall } from "../src/core/validation";

describe("isSolanaChainId", () => {
test("returns true for solana:mainnet", () => {
Expand Down Expand Up @@ -128,4 +129,18 @@ describe("validateDestinationCall", () => {
/route destination is Solana but call kind is "evm"/,
);
});

test("thrown error is a BridgeValidationError with route context", () => {
const destCall: DestinationCall = { kind: "evm", call: evmCall };
let error: BridgeValidationError | undefined;
try {
validateDestinationCall(destCall, svmRoute);
} catch (e) {
error = e as BridgeValidationError;
}
expect(error).toBeInstanceOf(BridgeValidationError);
expect(error?.code).toBe("VALIDATION");
expect(error?.outcome).toBe("user_fix");
expect(error?.route).toEqual(svmRoute);
});
});
Loading
Loading