Skip to content
Open
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
213 changes: 145 additions & 68 deletions src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
paramToScVal,
validateContext,
} from './batch-tx.js';
import { OperationAbortedError } from './errors.js';
import { OperationAbortedError, ValidationError, type ValidationIssue } from './errors.js';
import type { BatchTransactionContext, BuiltBatchTransaction, ScValType } from './batch-tx.js';

export interface SubmitOptions {
Expand Down Expand Up @@ -78,7 +78,7 @@ export class StreamBuilder {
* @returns The builder instance for chaining.
*/
token(address: string): this {
this._token = StreamBuilder._validateAddress(address, 'token');
this._token = address;
return this;
}

Expand All @@ -88,7 +88,7 @@ export class StreamBuilder {
* @returns The builder instance for chaining.
*/
sender(address: string): this {
this._sender = StreamBuilder._validateAddress(address, 'sender');
this._sender = address;
return this;
}

Expand All @@ -98,7 +98,7 @@ export class StreamBuilder {
* @returns The builder instance for chaining.
*/
recipient(address: string): this {
this._recipient = StreamBuilder._validateAddress(address, 'recipient');
this._recipient = address;
return this;
}

Expand All @@ -111,15 +111,6 @@ export class StreamBuilder {
* @returns The builder instance for chaining.
*/
amount(val: number | bigint): this {
if (typeof val === 'bigint') {
if (val <= 0n) {
throw new Error('Invalid StreamBuilder parameter: amount must be a positive value');
}
} else {
if (!Number.isFinite(val) || val <= 0) {
throw new Error('Invalid StreamBuilder parameter: amount must be a positive finite number');
}
}
this._amount = val;
return this;
}
Expand All @@ -133,15 +124,6 @@ export class StreamBuilder {
* @returns The builder instance for chaining.
*/
ratePerSecond(val: number | bigint): this {
if (typeof val === 'bigint') {
if (val <= 0n) {
throw new Error('Invalid StreamBuilder parameter: ratePerSecond must be a positive value');
}
} else {
if (!Number.isFinite(val) || val <= 0) {
throw new Error('Invalid StreamBuilder parameter: ratePerSecond must be a positive finite number');
}
}
this._ratePerSecond = val;
return this;
}
Expand All @@ -154,13 +136,6 @@ export class StreamBuilder {
* @returns The builder instance for chaining.
*/
startTime(val: number): this {
if (!Number.isInteger(val) || val < 0) {
throw new Error('Invalid StreamBuilder parameter: startTime must be a non-negative integer Unix timestamp');
}
const now = Math.floor(Date.now() / 1000);
if (val < now) {
throw new Error('Invalid StreamBuilder parameter: startTime cannot be in the past');
}
this._startTime = val;
return this;
}
Expand All @@ -173,9 +148,6 @@ export class StreamBuilder {
* @returns The builder instance for chaining.
*/
endTime(val: number): this {
if (!Number.isInteger(val) || val < 0) {
throw new Error('Invalid StreamBuilder parameter: endTime must be a non-negative integer Unix timestamp');
}
this._endTime = val;
return this;
}
Expand All @@ -187,35 +159,156 @@ export class StreamBuilder {
* @returns The builder instance for chaining.
*/
clawbackEnabled(val: boolean): this {
if (typeof val !== 'boolean') {
throw new Error('Invalid StreamBuilder parameter: clawbackEnabled must be a boolean');
}
this._clawbackEnabled = val;
return this;
}

/**
* Validates all configured builder fields and returns an array of any
* issues found. Returns an empty array if all fields are valid.
*
* Unlike {@link build}, this does not throw.
*/
validate(): ValidationIssue[] {
const issues: ValidationIssue[] = [];

// token
if (this._token === undefined) {
issues.push({ field: 'token', message: 'Missing required parameter: token' });
} else if (typeof this._token !== 'string' || this._token.trim().length === 0) {
issues.push({ field: 'token', message: 'Invalid StreamBuilder parameter: token must be a non-empty string' });
} else if (!StrKey.isValidContract(this._token)) {
issues.push({
field: 'token',
message: `Invalid StreamBuilder parameter: token must be a valid Soroban contract ID (C-address), got "${this._token}"`,
});
}

// sender
if (this._sender === undefined) {
issues.push({ field: 'sender', message: 'Missing required parameter: sender' });
} else if (typeof this._sender !== 'string' || this._sender.trim().length === 0) {
issues.push({ field: 'sender', message: 'Invalid StreamBuilder parameter: sender must be a non-empty string' });
} else if (!StrKey.isValidEd25519PublicKey(this._sender) && !StrKey.isValidContract(this._sender)) {
issues.push({
field: 'sender',
message: `Invalid StreamBuilder parameter: sender must be a valid Stellar public key or contract address (G-address or C-address), got "${this._sender}"`,
});
}

// recipient
if (this._recipient === undefined) {
issues.push({ field: 'recipient', message: 'Missing required parameter: recipient' });
} else if (typeof this._recipient !== 'string' || this._recipient.trim().length === 0) {
issues.push({ field: 'recipient', message: 'Invalid StreamBuilder parameter: recipient must be a non-empty string' });
} else if (!StrKey.isValidEd25519PublicKey(this._recipient) && !StrKey.isValidContract(this._recipient)) {
issues.push({
field: 'recipient',
message: `Invalid StreamBuilder parameter: recipient must be a valid Stellar public key or contract address (G-address or C-address), got "${this._recipient}"`,
});
}

// amount
if (this._amount === undefined) {
issues.push({ field: 'amount', message: 'Missing required parameter: amount' });
} else if (typeof this._amount === 'bigint') {
if (this._amount <= 0n) {
issues.push({ field: 'amount', message: 'Invalid StreamBuilder parameter: amount must be a positive value' });
}
} else if (typeof this._amount === 'number') {
if (!Number.isFinite(this._amount) || this._amount <= 0) {
issues.push({ field: 'amount', message: 'Invalid StreamBuilder parameter: amount must be a positive finite number' });
}
} else {
issues.push({ field: 'amount', message: 'Invalid StreamBuilder parameter: amount must be a positive finite number' });
}

// ratePerSecond (optional)
if (this._ratePerSecond !== undefined && this._ratePerSecond !== null) {
if (typeof this._ratePerSecond === 'bigint') {
if (this._ratePerSecond <= 0n) {
issues.push({ field: 'ratePerSecond', message: 'Invalid StreamBuilder parameter: ratePerSecond must be a positive value' });
}
} else if (typeof this._ratePerSecond === 'number') {
if (!Number.isFinite(this._ratePerSecond) || this._ratePerSecond <= 0) {
issues.push({ field: 'ratePerSecond', message: 'Invalid StreamBuilder parameter: ratePerSecond must be a positive finite number' });
}
} else {
issues.push({ field: 'ratePerSecond', message: 'Invalid StreamBuilder parameter: ratePerSecond must be a positive number or bigint' });
}
}

// startTime (optional)
if (this._startTime !== undefined && this._startTime !== null) {
if (typeof this._startTime !== 'number' || !Number.isInteger(this._startTime) || this._startTime < 0) {
issues.push({ field: 'startTime', message: 'Invalid StreamBuilder parameter: startTime must be a non-negative integer Unix timestamp' });
} else {
const now = Math.floor(Date.now() / 1000);
if (this._startTime < now) {
issues.push({ field: 'startTime', message: 'Invalid StreamBuilder parameter: startTime cannot be in the past' });
}
}
}

// endTime (optional)
if (this._endTime !== undefined && this._endTime !== null) {
if (typeof this._endTime !== 'number' || !Number.isInteger(this._endTime) || this._endTime < 0) {
issues.push({ field: 'endTime', message: 'Invalid StreamBuilder parameter: endTime must be a non-negative integer Unix timestamp' });
} else if (
typeof this._startTime === 'number' &&
this._endTime > 0 &&
this._endTime <= this._startTime
) {
issues.push({ field: 'endTime', message: 'Invalid StreamBuilder parameter: endTime must be greater than startTime' });
}
}

// clawbackEnabled (optional)
if (this._clawbackEnabled !== undefined && this._clawbackEnabled !== null) {
if (typeof this._clawbackEnabled !== 'boolean') {
issues.push({ field: 'clawbackEnabled', message: 'Invalid StreamBuilder parameter: clawbackEnabled must be a boolean' });
}
}

return issues;
}

/**
* Validates and produces the final stream configuration.
* Any bigint fields are converted to strings to guarantee safe
* serialisation across all browsers (Safari/WebKit included).
* @returns An object containing `token`, `sender`, `recipient`, `amount`, and optionally `ratePerSecond`.
* @throws {Error} If any required field (`token`, `sender`, `recipient`, `amount`) is missing or malformed.
* @throws {ValidationError} If any field is missing or malformed, collecting all issues in `error.issues`.
* @throws {Error} If the builder has been destroyed.
*/
build() {
if (this.isDestroyed) {
throw new Error('StreamBuilder has been destroyed');
}
if (this._token === undefined || this._token === null ||
this._sender === undefined || this._sender === null ||
this._recipient === undefined || this._recipient === null ||
this._amount === undefined || this._amount === null) {
throw new Error('Missing required parameters for StreamBuilder');

const issues = this.validate();
if (issues.length > 0) {
const missingFields = issues
.filter((i) => i.message.startsWith('Missing required parameter'))
.map((i) => i.field);
const invalidIssues = issues.filter((i) => !i.message.startsWith('Missing required parameter'));

let summary = '';
if (missingFields.length > 0 && invalidIssues.length === 0) {
summary = `Missing required parameters for StreamBuilder: ${missingFields.join(', ')}`;
} else if (invalidIssues.length > 0 && missingFields.length === 0) {
summary = `Invalid StreamBuilder parameter: ${invalidIssues.map((i) => i.message).join('; ')}`;
} else {
summary = `Missing required parameters for StreamBuilder: ${missingFields.join(', ')}; Invalid StreamBuilder parameter: ${invalidIssues.map((i) => i.message).join('; ')}`;
}

throw new ValidationError(issues, summary);
}

const config: Record<string, unknown> = {
token: this._token,
sender: this._sender,
recipient: this._recipient,
token: this._token as string,
sender: this._sender as string,
recipient: this._recipient as string,
// Coerce to string regardless of input type: build()'s return type
// promises `amount: string`, and ConduitBatcher's payload validation
// rejects a raw `number` (a float-precision hazard for token amounts).
Expand Down Expand Up @@ -270,7 +363,14 @@ export class StreamBuilder {
toContractArgs(): unknown[] {
const config = this.build();
if (this._ratePerSecond === undefined || this._ratePerSecond === null) {
throw new Error(
throw new ValidationError(
[
{
field: 'ratePerSecond',
message:
'Invalid StreamBuilder parameter: ratePerSecond is required to build create_stream contract arguments',
},
],
'Invalid StreamBuilder parameter: ratePerSecond is required to build create_stream contract arguments',
);
}
Expand Down Expand Up @@ -412,29 +512,6 @@ export class StreamBuilder {
this.activeTimers.clear();
this.pendingQueue = [];
}

private static _validateAddress(address: string, field: string): string {
if (typeof address !== 'string' || address.trim().length === 0) {
throw new Error(`Invalid StreamBuilder parameter: ${field} must be a non-empty string`);
}

if (field === 'token') {
if (!StrKey.isValidContract(address)) {
throw new Error(
`Invalid StreamBuilder parameter: ${field} must be a valid Soroban contract ID (C-address), got "${address}"`,
);
}
} else {
// sender / recipient — must be valid Stellar addresses (G-address or C-address)
if (!StrKey.isValidEd25519PublicKey(address) && !StrKey.isValidContract(address)) {
throw new Error(
`Invalid StreamBuilder parameter: ${field} must be a valid Stellar public key or contract address (G-address or C-address), got "${address}"`,
);
}
}

return address;
}
}

export interface BatchOperation {
Expand Down
51 changes: 51 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,54 @@ export class OperationAbortedError extends Error {
}
}

// ── Validation error (#631) ───────────────────────────────────────────────────

export interface ValidationIssue {
/** The field that failed validation (e.g. 'token', 'sender', 'recipient', 'amount'). */
field?: string;
/** Human-readable explanation of why validation failed. */
message: string;
}

/**
* Thrown when validation fails across one or more builder or payload fields.
* Aggregates all detected problems into a `.issues[]` array rather than halting
* on the first invalid field.
*
* @example
* ```ts
* try {
* new StreamBuilder().token('bad').sender('bad').build();
* } catch (err) {
* if (err instanceof ValidationError) {
* console.error(err.issues); // [{ field: 'token', ... }, { field: 'sender', ... }]
* }
* }
* ```
*/
export class ValidationError extends Error {
/** The list of all validation issues detected. */
readonly issues: readonly ValidationIssue[];

constructor(issues: (ValidationIssue | string)[], message?: string) {
const normalizedIssues: ValidationIssue[] = issues.map((issue) =>
typeof issue === 'string' ? { message: issue } : issue,
);
const summary =
message ??
(normalizedIssues.length === 1
? normalizedIssues[0]!.message
: `Validation failed with ${normalizedIssues.length} issues:\n` +
normalizedIssues
.map((i) => ` - ${i.field ? `[${i.field}] ` : ''}${i.message}`)
.join('\n'));
super(summary);
this.name = 'ValidationError';
this.issues = normalizedIssues;
Object.setPrototypeOf(this, new.target.prototype);
}
}

// ── Type guard ────────────────────────────────────────────────────────────────

/**
Expand All @@ -506,6 +554,7 @@ export class OperationAbortedError extends Error {
* - {@link RpcServiceUnavailableError}
* - {@link IndexerTimeoutError}
* - {@link OperationAbortedError}
* - {@link ValidationError}
*/
export function isConduitError(value: unknown): value is Error {
if (!(value instanceof Error)) return false;
Expand All @@ -518,5 +567,7 @@ export function isConduitError(value: unknown): value is Error {
'RpcServiceUnavailableError',
'IndexerTimeoutError',
'OperationAbortedError',
'ValidationError',
].includes(value.name);
}

3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,13 @@ export {
RpcServiceUnavailableError,
IndexerTimeoutError,
OperationAbortedError,
ValidationError,
isConduitError,
SUPPORTED_NETWORKS,
CAIP2_TO_NETWORK,
UNKNOWN_CONTRACT_ERROR_CODE,
} from './errors.js';
export type { ConduitContract } from './errors.js';
export type { ConduitContract, ValidationIssue } from './errors.js';
export * from './types/index.js';
export type { GetStreamInfosOptions, GetStreamInfosResult, GetStreamInfosFailure } from './types/index.js';
export * from './adapters/index.js';
Expand Down
Loading