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
99 changes: 90 additions & 9 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,46 @@ import type {
} from "./types.js";
import { signTransaction } from "./wallet.js";

// ---------------------------------------------------------------------------
// Supported chain IDs & mismatch error
// ---------------------------------------------------------------------------

/**
* The set of chain IDs that this SDK's bridge module accepts.
* Any `targetChainId` outside this list will cause `buildBridgePayment` and
* `submitBridgePayment` to throw a {@link BridgeChainMismatchError}.
*/
export const SUPPORTED_CHAIN_IDS: readonly ChainId[] = ["ethereum", "solana"];

/**
* Thrown when `BridgeOptions.targetChainId` is absent or not present in
* {@link SUPPORTED_CHAIN_IDS}. Because a mismatch would silently route funds
* to the wrong chain, the error is raised before any transaction is built.
*/
export class BridgeChainMismatchError extends Error {
constructor(targetChainId: string | undefined) {
super(
targetChainId === undefined || targetChainId === null || targetChainId === ""
? `targetChainId is required. Supported chain IDs: [${SUPPORTED_CHAIN_IDS.join(", ")}]`
: `Unsupported targetChainId "${targetChainId}". Supported chain IDs: [${SUPPORTED_CHAIN_IDS.join(", ")}]`,
);
this.name = "BridgeChainMismatchError";
}
}

/**
* Options passed alongside bridge payment parameters to specify and validate
* the intended destination chain.
*/
export interface BridgeOptions {
/**
* The chain the funds should be routed to. Must be one of
* {@link SUPPORTED_CHAIN_IDS}; an unsupported or missing value throws
* {@link BridgeChainMismatchError} before any transaction is built.
*/
targetChainId: ChainId;
}

// ---------------------------------------------------------------------------
// Per-chain bridge configuration
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -223,12 +263,26 @@ export async function estimateBridgeFee(
* source-chain wallet before it can be submitted via
* {@link submitBridgePayment}.
*
* @param params - Payment parameters (chain, payer, invoiceId, amount, etc.).
* @param params - Payment parameters (chain, payer, invoiceId, amount, etc.).
* @param options - Bridge options; `targetChainId` must be a supported chain.
* @returns Unsigned BridgePaymentRequest ready for source-chain signing.
* @throws {BridgeChainMismatchError} if `options.targetChainId` is absent or
* not in {@link SUPPORTED_CHAIN_IDS}.
*/
export function buildBridgePayment(
params: BridgePaymentParams,
options?: BridgeOptions,
): BridgePaymentRequest {
// Validate targetChainId before building any transaction.
const targetChainId = options?.targetChainId;
if (
targetChainId === undefined ||
targetChainId === null ||
!(SUPPORTED_CHAIN_IDS as readonly string[]).includes(targetChainId)
) {
throw new BridgeChainMismatchError(targetChainId);
}

const {
sourceChain,
payer,
Expand Down Expand Up @@ -303,14 +357,41 @@ export interface BridgePayDeps {
*
* @param proof - Signed bridge proof from the source-chain wallet.
* @param clientConfig - StellarSplitClient configuration (rpcUrl, contractId, etc.).
* @param options - Bridge options; `targetChainId` must be a supported chain.
* @param _deps - Optional injectable dependencies (for testing only).
* @returns Transaction hash of the submitted bridge payment.
* @throws {BridgeChainMismatchError} if `options.targetChainId` is absent or
* not in {@link SUPPORTED_CHAIN_IDS}.
*/
export async function submitBridgePayment(
proof: SignedBridgeProof,
clientConfig: StellarSplitClientConfig,
options?: BridgeOptions | BridgePayDeps,
_deps?: BridgePayDeps,
): Promise<{ txHash: string }> {
// Normalise the overloaded signature: submitBridgePayment(proof, cfg, deps)
// was the old public shape; new shape is submitBridgePayment(proof, cfg, options, deps).
let resolvedOptions: BridgeOptions | undefined;
let resolvedDeps: BridgePayDeps | undefined;

if (options !== undefined && "targetChainId" in options) {
resolvedOptions = options as BridgeOptions;
resolvedDeps = _deps;
} else {
// Legacy call: third arg is actually deps (no options).
resolvedDeps = options as BridgePayDeps | undefined;
}

// Validate targetChainId before touching the network.
const targetChainId = resolvedOptions?.targetChainId;
if (
targetChainId === undefined ||
targetChainId === null ||
!(SUPPORTED_CHAIN_IDS as readonly string[]).includes(targetChainId)
) {
throw new BridgeChainMismatchError(targetChainId);
}

const { request, signature } = proof;

if (!request.payloadHash) {
Expand All @@ -325,14 +406,14 @@ export async function submitBridgePayment(
? clientConfig.rpcUrl[0]!
: clientConfig.rpcUrl;

const server: SorobanServerLike = _deps?.server ?? new SorobanRpc.Server(rpcUrl, {
const server: SorobanServerLike = resolvedDeps?.server ?? new SorobanRpc.Server(rpcUrl, {
allowHttp: rpcUrl.startsWith("http://"),
});

// Build the bridge_pay contract call operation via injectable or real Contract
let operation: any;
if (_deps?.contractCall) {
operation = _deps.contractCall(
if (resolvedDeps?.contractCall) {
operation = resolvedDeps.contractCall(
"bridge_pay",
request.invoiceId,
request.payer,
Expand Down Expand Up @@ -360,8 +441,8 @@ export async function submitBridgePayment(

// Build the transaction (injectable for tests)
let tx: any;
if (_deps?.buildTx) {
tx = _deps.buildTx(account, operation, clientConfig.networkPassphrase);
if (resolvedDeps?.buildTx) {
tx = resolvedDeps.buildTx(account, operation, clientConfig.networkPassphrase);
} else {
tx = new TransactionBuilder(account as unknown as Account, {
fee: BASE_FEE,
Expand All @@ -379,7 +460,7 @@ export async function submitBridgePayment(
}

// Assemble and sign
const _assembleTransaction = _deps?.assembleTransaction ?? SorobanRpc.assembleTransaction;
const _assembleTransaction = resolvedDeps?.assembleTransaction ?? SorobanRpc.assembleTransaction;
const preparedTx = _assembleTransaction(tx, simResult).build();
const preparedXdr: string =
typeof preparedTx === "string"
Expand All @@ -391,12 +472,12 @@ export async function submitBridgePayment(
const adapter = (clientConfig as {
adapter?: { signTransaction: (xdr: string, network: string) => Promise<string> };
}).adapter;
const _sign = _deps?.signTransaction ?? (adapter?.signTransaction.bind(adapter)) ??
const _sign = resolvedDeps?.signTransaction ?? (adapter?.signTransaction.bind(adapter)) ??
((xdr: string, network: string) => signTransaction(xdr, network));
const signedXdr = await _sign(preparedXdr, clientConfig.networkPassphrase);

// Submit
const _fromXDR = _deps?.fromXDR ?? TransactionBuilder.fromXDR.bind(TransactionBuilder);
const _fromXDR = resolvedDeps?.fromXDR ?? TransactionBuilder.fromXDR.bind(TransactionBuilder);
const sendResult = await server.sendTransaction(
_fromXDR(signedXdr, clientConfig.networkPassphrase),
);
Expand Down
69 changes: 65 additions & 4 deletions src/bulkImportValidator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { CreateInvoiceParams } from "./types.js";

/**
* Schema versions that this SDK version accepts for bulk import payloads.
* Callers can inspect this list to pre-screen payloads before submission.
*/
export const SUPPORTED_SCHEMA_VERSIONS: readonly number[] = [1, 2];

/**
* Describes a single validation error on a specific row and field.
*/
Expand All @@ -23,9 +29,23 @@ export interface BulkImportValidationResult {
}

/**
* Validate an array of invoice-creation rows against the same constraints
* the contract's `_create_invoice_inner` enforces:
* A bulk import payload that carries a `schemaVersion` alongside the rows.
* When this overload is used the version is validated before row-level checks.
*/
export interface BulkImportPayload {
/** Must be one of the values in {@link SUPPORTED_SCHEMA_VERSIONS}. */
schemaVersion: number;
rows: CreateInvoiceParams[];
}

/**
* Validate an array of invoice-creation rows (or a versioned payload) against
* the same constraints the contract's `_create_invoice_inner` enforces:
*
* 0. **Schema version present and supported** – when a
* {@link BulkImportPayload} is supplied, `schemaVersion` must appear in
* {@link SUPPORTED_SCHEMA_VERSIONS}. Absent or unsupported versions cause
* an immediate error before any row-level validation runs.
* 1. **Positive amounts** – every recipient amount must be > 0.
* 2. **Recipients present** – `recipients` array must not be empty.
* 3. **Future deadline** – `deadline` must be in the future (greater than
Expand All @@ -34,13 +54,54 @@ export interface BulkImportValidationResult {
* Unlike a fail-fast approach, *all* rows are always validated so the caller
* can surface every problem in one pass.
*
* @param rows - Array of {@link CreateInvoiceParams}-like objects to validate.
* @param input - Either a raw array of {@link CreateInvoiceParams} rows, or a
* {@link BulkImportPayload} object that also carries a `schemaVersion`.
* @returns A {@link BulkImportValidationResult} with valid row indices and
* collected per-row errors.
*/
export function validateBulkImport(
rows: CreateInvoiceParams[],
input: CreateInvoiceParams[] | BulkImportPayload,
): BulkImportValidationResult {
// Unwrap a versioned payload, validating the schema version first.
let rows: CreateInvoiceParams[];

if (Array.isArray(input)) {
rows = input;
} else {
const { schemaVersion, rows: payloadRows } = input;

if (schemaVersion === undefined || schemaVersion === null) {
return {
validRows: [],
errors: [
{
row: -1,
field: "schemaVersion",
message:
`schemaVersion is required. Supported versions: [${SUPPORTED_SCHEMA_VERSIONS.join(", ")}]`,
},
],
};
}

if (!(SUPPORTED_SCHEMA_VERSIONS as readonly number[]).includes(schemaVersion)) {
return {
validRows: [],
errors: [
{
row: -1,
field: "schemaVersion",
message:
`Unsupported schemaVersion ${schemaVersion}. Supported versions: [${SUPPORTED_SCHEMA_VERSIONS.join(", ")}]`,
},
],
};
}

rows = payloadRows;
}

// --- row-level validation (unchanged) ---
const validRows: number[] = [];
const errors: BulkImportRowError[] = [];

Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ export {
} from "./invoiceTemplate.js";
export {
validateBulkImport,
SUPPORTED_SCHEMA_VERSIONS,
} from "./bulkImportValidator.js";
export type {
BulkImportRowError,
BulkImportValidationResult,
BulkImportPayload,
} from "./bulkImportValidator.js";
export {
StellarSplitError,
Expand Down Expand Up @@ -732,6 +734,7 @@ export type {
export {
AdaptiveThrottle,
DEFAULT_PENALTY_DURATION_MS,
DEFAULT_MAX_BACKOFF_MS,
} from "./throttle/AdaptiveThrottle.js";
export type { AdaptiveThrottleConfig, ThrottleStats } from "./throttle/AdaptiveThrottle.js";
export { parseRateLimitHeaders } from "./throttle/RateLimitParser.js";
Expand Down Expand Up @@ -1131,9 +1134,11 @@ export {
submitBridgePayment,
computePayloadHash,
DEFAULT_CHAIN_CONFIGS,
SUPPORTED_CHAIN_IDS,
BridgeChainMismatchError,
} from "./bridge.js";

export type { ChainBridgeConfig, BridgeConfig } from "./bridge.js";
export type { ChainBridgeConfig, BridgeConfig, BridgeOptions } from "./bridge.js";

export type {
ChainId,
Expand All @@ -1149,6 +1154,7 @@ export type {
TimelineEntry,
TimelineEventType,
TimelineSource,
TimelineEntryStatus,
ReconstructedTimeline,
RebuildOptions,
} from "./types/timeline.js";
Expand Down
Loading