diff --git a/src/errors.ts b/src/errors.ts index 158c0eb..f795007 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1320,3 +1320,246 @@ export function isRecoverableError(err: unknown): boolean { } return false; } + +// ── Contract error parsing ──────────────────────────────────────────────────── + +/** + * A human-readable diagnostic for a contract error code, keyed by the contract + * that raised it. Used by {@link parseContractError} to describe codes that + * cannot be mapped to a dedicated error class. + */ +export interface ContractErrorDiagnostic { + /** The contract that raised the error. */ + contract: ConduitContract; + /** The numeric contract error code. */ + code: number; + /** The human-readable message for `code` on `contract`. */ + message: string; +} + +/** + * Extra context used to build the richer typed error classes. + * + * The dedicated error classes for some stream codes carry details that cannot + * be recovered from the numeric code alone (the stream ID, the attempted + * operation, the rejected caller). When this context is not supplied, + * {@link parseContractError} falls back to a {@link ConduitError} — which + * still carries the correct contract-scoped message — rather than inventing + * placeholder values. + */ +export interface ContractErrorContext { + /** The stream ID the failing call was operating on. */ + streamId?: bigint | string; + /** The lifecycle operation that was attempted, e.g. `"withdraw"`. */ + operation?: string; + /** The address that was rejected by the contract's auth check. */ + caller?: string; +} + +/** + * Error raised for a contract error code that could be parsed but does not map + * to one of the dedicated typed errors — either because the code has no + * dedicated class, or because the contract it belongs to could not be + * determined unambiguously. + * + * Carries the parsed code (and diagnostic, when the contract is known) so + * callers can still surface a meaningful message. + * + * @example + * ```ts + * try { + * await client.streams.withdraw(streamId); + * } catch (err) { + * const parsed = parseContractError(err, 'stream'); + * if (parsed instanceof ContractError) { + * console.error(`Contract error #${parsed.code}: ${parsed.message}`); + * } + * } + * ``` + */ +export class ContractError extends Error { + /** The contract that raised the error, when it could be determined. */ + readonly contract: ConduitContract | undefined; + /** The numeric contract error code, when one could be extracted. */ + readonly code: number | undefined; + /** The parsed diagnostic, when the code could be resolved to a contract. */ + readonly diagnostic: ContractErrorDiagnostic | undefined; + + constructor( + message: string, + contract?: ConduitContract, + code?: number, + diagnostic?: ContractErrorDiagnostic, + ) { + super(message); + this.name = 'ContractError'; + this.contract = contract; + this.code = code; + this.diagnostic = diagnostic; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Extracts a numeric contract error code from a raw error value. + * + * Recognises the shapes a Soroban contract error takes on the wire: + * - `Error(Contract, #12)` embedded in an error message (the host error format); + * - an object carrying a numeric `code` property (e.g. an `xdr.ScError`); + * - an `Error` whose `cause` chain contains either of the above. + * + * Returns `undefined` when no contract error code can be found. + * + * @internal + */ +function extractContractErrorCode(error: unknown): number | undefined { + let current: unknown = error; + for (let depth = 0; depth < 5 && current != null; depth++) { + if (typeof current === 'object') { + const code = (current as { code?: unknown }).code; + if (typeof code === 'number' && Number.isInteger(code) && code > 0) { + return code; + } + } + if (typeof current === 'string') { + const match = /Error\(Contract,\s*#(\d+)\)/.exec(current); + if (match?.[1]) return Number(match[1]); + } + if (current instanceof Error) { + const match = /Error\(Contract,\s*#(\d+)\)/.exec(current.message); + if (match?.[1]) return Number(match[1]); + } + current = (current as { cause?: unknown }).cause; + } + return undefined; +} + +/** + * Returns the contracts whose error catalogue defines `code`. + * + * Because error numbers are contract-scoped, the same numeric code can mean + * different things on different contracts (e.g. code 1 is `NotAuthorized` on + * DripStream/DripGovernor but `NotInitialized` on DripFactory). When more than + * one contract defines a code, the code alone cannot identify the contract. + * + * @internal + */ +function contractsDefiningCode(code: number): ConduitContract[] { + return (Object.keys(MESSAGES_BY_CONTRACT) as ConduitContract[]).filter( + (contract) => code in MESSAGES_BY_CONTRACT[contract], + ); +} + +/** + * Parses an arbitrary error into a typed contract error. + * + * Soroban host errors surface as raw integers (e.g. `Error(Contract, #12)`), + * which makes them hard to diagnose. This function extracts the numeric + * contract error code from an unknown value and maps it to the matching typed + * error class — {@link StreamNotFoundError}, {@link UnauthorizedStreamActionError}, + * {@link InvalidStreamStateError}, {@link ClawbackNotEnabledError}, + * {@link RateExceedsMaxError}, {@link DurationTooShortError}, + * {@link BackdatedStreamError}, {@link GovernorInvalidParamError} — or to a + * {@link ConduitError} carrying the contract-scoped message. + * + * Error numbers are contract-scoped, so the `contract` argument tells the + * parser which catalogue to read. When it is omitted, the parser only resolves + * the code automatically if exactly one contract defines it; otherwise it + * returns a {@link ContractError} listing the candidates instead of guessing + * and reporting the wrong meaning. + * + * Errors that do not carry a contract error code (network failures, timeouts, + * user cancellations) are returned unchanged, so this is safe to call in a + * catch block. + * + * @param error - The raw error value to parse. + * @param contract - The contract the error came from, when known. + * @param context - Optional details used to build the richer typed errors. + * @returns A typed contract error, or the original error when it carries no + * contract error code. + * + * @example + * ```ts + * try { + * await client.streams.withdraw(streamId); + * } catch (err) { + * const parsed = parseContractError(err, 'stream', { streamId, operation: 'withdraw' }); + * if (parsed instanceof ClawbackNotEnabledError) { + * console.error('Clawback was not enabled for this stream.'); + * } else if (parsed instanceof ContractError) { + * console.error(`Contract error #${parsed.code}: ${parsed.message}`); + * } + * } + * ``` + */ +export function parseContractError( + error: unknown, + contract?: ConduitContract, + context: ContractErrorContext = {}, +): Error { + // Already a typed contract error — nothing to parse. + if (error instanceof ConduitError) return error; + + const code = extractContractErrorCode(error); + if (code === undefined) { + // Not a contract error (network failure, timeout, user abort, …). + return error instanceof Error ? error : new Error(String(error)); + } + + // Determine which contract's catalogue to read. + let scope: ConduitContract; + if (contract) { + scope = contract; + } else { + const candidates = contractsDefiningCode(code); + if (candidates.length !== 1) { + const options = candidates + .map((c) => `${c} #${code} (${MESSAGES_BY_CONTRACT[c][code]})`) + .join(', '); + return new ContractError( + candidates.length === 0 + ? `Unknown contract error code #${code}.` + : `Contract error code #${code} is ambiguous — it is defined by more than one contract: ${options}. Pass the contract to disambiguate.`, + undefined, + code, + ); + } + scope = candidates[0] as ConduitContract; + } + + if (!(code in MESSAGES_BY_CONTRACT[scope])) { + return new ContractError( + `Unknown ${scope} contract error code #${code}.`, + scope, + code, + ); + } + + // Map to the dedicated typed error class where one exists. + switch (scope) { + case 'governor': + if (code === GovernorErrorCode.InvalidParam) { + return new GovernorInvalidParamError(); + } + break; + case 'factory': + if (code === FactoryErrorCode.RateExceedsMax) return new RateExceedsMaxError(); + if (code === FactoryErrorCode.DurationTooShort) return new DurationTooShortError(); + if (code === FactoryErrorCode.BackdatedStream) return new BackdatedStreamError(); + break; + case 'stream': + if (code === StreamErrorCode.ClawbackDisabled) return new ClawbackNotEnabledError(); + if (code === StreamErrorCode.NotAuthorized && context.operation && context.caller) { + return new UnauthorizedStreamActionError(context.operation, context.caller); + } + if (code === StreamErrorCode.StreamNotFound && context.streamId !== undefined) { + return new StreamNotFoundError(context.streamId); + } + if (code in STATE_BY_CODE && context.operation) { + return new InvalidStreamStateError(context.operation, code as StreamErrorCode); + } + break; + } + + return new ConduitError(scope, code); +} diff --git a/src/index.ts b/src/index.ts index 7118876..c469171 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,11 +96,15 @@ export { SUPPORTED_NETWORKS, CAIP2_TO_NETWORK, UNKNOWN_CONTRACT_ERROR_CODE, + parseContractError, + ContractError, } from "./errors.js"; export type { ConduitContract, StreamLifecycleState, SorobanDiagnosticInfo, + ContractErrorContext, + ContractErrorDiagnostic, } from "./errors.js"; export * from "./types/index.js"; export type { diff --git a/src/streams.ts b/src/streams.ts index 1e5f020..f4739d2 100644 --- a/src/streams.ts +++ b/src/streams.ts @@ -21,8 +21,10 @@ import type { StreamInfo, Subscription, BatchWithdrawItem, + BatchWithdrawPayload, BatchWithdrawResult, BatchCreateStreamResult, + BatchStreamCreationPayload, StreamConfig, StreamOperation, FeeEstimate, @@ -674,7 +676,7 @@ export class StreamsModule { * sequence number. */ async batchWithdraw( - withdrawals: BatchWithdrawItem[], + withdrawals: BatchWithdrawPayload[], ): Promise { this._ensureCanMutate(); @@ -716,7 +718,7 @@ export class StreamsModule { * whole batch, not just that config. */ async createBatchStreams( - configs: StreamConfig[], + configs: BatchStreamCreationPayload[], ): Promise { this._ensureCanMutate(); if (!Array.isArray(configs) || configs.length === 0) return []; diff --git a/src/tests/batch-payload-types.test.ts b/src/tests/batch-payload-types.test.ts new file mode 100644 index 0000000..9bcd3a1 --- /dev/null +++ b/src/tests/batch-payload-types.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, expectTypeOf } from 'vitest'; +import type { + BatchStreamCreationPayload, + BatchWithdrawPayload, + BatchWithdrawItem, + StreamConfig, +} from '../types/index.js'; + +// Compile-time helpers: assigning an invalid object to a typed parameter makes +// the compiler reject the call, which `@ts-expect-error` asserts. +function acceptCreation(_payload: BatchStreamCreationPayload): void {} +function acceptWithdraw(_payload: BatchWithdrawPayload): void {} + +describe('batch payload types', () => { + it('requires recipient, token and depositAmount for a batch creation payload', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + + // @ts-expect-error recipient is required + acceptCreation({ token: 'native', depositAmount: '100' }); + + // @ts-expect-error depositAmount must be a string + acceptCreation({ recipient: 'GABC', token: 'native', depositAmount: 100 }); + + // A complete payload is accepted. + acceptCreation({ recipient: 'GABC', token: 'native', depositAmount: '100' }); + expect(true).toBe(true); + }); + + it('requires a streamId for a batch withdraw payload', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + + // @ts-expect-error streamId is required + acceptWithdraw({ amount: 1n }); + + acceptWithdraw({ streamId: 1n, amount: 2n }); + expect(true).toBe(true); + }); + + it('keeps the legacy item alias assignable to the payload type', () => { + const item: BatchWithdrawItem = { streamId: 1n, amount: 2n }; + const payload: BatchWithdrawPayload = item; + expect(payload.streamId).toBe(1n); + }); + + it('keeps StreamConfig assignable to the batch creation payload', () => { + const config: StreamConfig = { + recipient: 'GABC', + token: 'native', + depositAmount: '100', + }; + const payload: BatchStreamCreationPayload = config; + expect(payload.recipient).toBe('GABC'); + }); +}); diff --git a/src/tests/parse-contract-error.test.ts b/src/tests/parse-contract-error.test.ts new file mode 100644 index 0000000..6b7fb4f --- /dev/null +++ b/src/tests/parse-contract-error.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { + parseContractError, + ContractError, + ConduitError, + StreamErrorCode, + FactoryErrorCode, + GovernorErrorCode, + StreamNotFoundError, + UnauthorizedStreamActionError, + InvalidStreamStateError, + ClawbackNotEnabledError, + RateExceedsMaxError, + DurationTooShortError, + BackdatedStreamError, + GovernorInvalidParamError, + StreamFiNetworkError, +} from '../errors.js'; + +describe('parseContractError', () => { + it('maps a host-error message to the contract-scoped ConduitError', () => { + const err = parseContractError( + new Error('HostError: Error(Contract, #3)'), + 'stream', + ); + expect(err).toBeInstanceOf(ConduitError); + expect((err as ConduitError).contract).toBe('stream'); + expect((err as ConduitError).code).toBe(StreamErrorCode.StreamCancelled); + expect(err.message).toMatch(/cancelled/i); + }); + + it('maps a raw code object to the matching typed error class', () => { + const err = parseContractError({ code: FactoryErrorCode.RateExceedsMax }, 'factory'); + expect(err).toBeInstanceOf(RateExceedsMaxError); + }); + + it('maps a governor InvalidParam code to GovernorInvalidParamError', () => { + const err = parseContractError({ code: GovernorErrorCode.InvalidParam }, 'governor'); + expect(err).toBeInstanceOf(GovernorInvalidParamError); + }); + + it('maps factory duration/rate/backdated codes to their typed errors', () => { + expect(parseContractError({ code: 9 }, 'factory')).toBeInstanceOf(DurationTooShortError); + expect(parseContractError({ code: 6 }, 'factory')).toBeInstanceOf(BackdatedStreamError); + }); + + it('maps the stream clawback code to ClawbackNotEnabledError', () => { + const err = parseContractError({ code: StreamErrorCode.ClawbackDisabled }, 'stream'); + expect(err).toBeInstanceOf(ClawbackNotEnabledError); + }); + + it('uses the context to build richer stream errors', () => { + const notFound = parseContractError({ code: StreamErrorCode.StreamNotFound }, 'stream', { + streamId: 42n, + }); + expect(notFound).toBeInstanceOf(StreamNotFoundError); + expect((notFound as StreamNotFoundError).streamId).toBe(42n); + + const unauthorized = parseContractError({ code: StreamErrorCode.NotAuthorized }, 'stream', { + operation: 'cancel', + caller: 'GABC', + }); + expect(unauthorized).toBeInstanceOf(UnauthorizedStreamActionError); + expect((unauthorized as UnauthorizedStreamActionError).operation).toBe('cancel'); + expect((unauthorized as UnauthorizedStreamActionError).caller).toBe('GABC'); + + const badState = parseContractError({ code: StreamErrorCode.StreamEnded }, 'stream', { + operation: 'withdraw', + }); + expect(badState).toBeInstanceOf(InvalidStreamStateError); + expect((badState as InvalidStreamStateError).currentState).toBe('ended'); + }); + + it('falls back to ConduitError when the rich context is missing', () => { + const err = parseContractError({ code: StreamErrorCode.StreamNotFound }, 'stream'); + expect(err).toBeInstanceOf(ConduitError); + expect(err).not.toBeInstanceOf(StreamNotFoundError); + expect(err.message).toMatch(/not found/i); + }); + + it('returns an already-typed contract error unchanged', () => { + const original = new ClawbackNotEnabledError(); + expect(parseContractError(original, 'stream')).toBe(original); + }); + + it('returns non-contract errors unchanged', () => { + const network = new StreamFiNetworkError('fetch failed'); + expect(parseContractError(network, 'stream')).toBe(network); + + const plain = new Error('something else'); + expect(parseContractError(plain)).toBe(plain); + }); + + it('resolves the contract automatically when only one catalogue defines the code', () => { + // Stream code 14 is unique to the stream catalogue. + const err = parseContractError({ code: 14 }); + expect(err).toBeInstanceOf(ConduitError); + expect((err as ConduitError).contract).toBe('stream'); + }); + + it('reports ambiguity instead of guessing when several contracts define the code', () => { + // Code 1 exists on all three contracts with different meanings. + const err = parseContractError({ code: 1 }); + expect(err).toBeInstanceOf(ContractError); + expect((err as ContractError).code).toBe(1); + expect(err.message).toMatch(/ambiguous/i); + }); + + it('reports an unknown code for the given contract', () => { + const err = parseContractError({ code: 999 }, 'stream'); + expect(err).toBeInstanceOf(ContractError); + expect((err as ContractError).code).toBe(999); + expect(err.message).toMatch(/unknown/i); + }); + + it('extracts the code from a nested cause chain', () => { + const inner = new Error('HostError: Error(Contract, #6)'); + const outer = new Error('wrapped', { cause: inner }); + const err = parseContractError(outer, 'stream'); + expect(err).toBeInstanceOf(ConduitError); + expect((err as ConduitError).code).toBe(StreamErrorCode.NothingToWithdraw); + }); + + it('wraps a non-Error value that carries no contract code', () => { + const err = parseContractError('just a string'); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe('just a string'); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index dfc7328..34c6c90 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -211,9 +211,6 @@ export interface CreateStreamParams { ratePerSecond?: string; } -/** Configuration for a single stream in a batch creation. */ -export type StreamConfig = CreateStreamParams; - export interface ValidationResult { isValid: boolean; errors: string[]; @@ -464,12 +461,74 @@ export interface Subscription { // -- Batch operations ---------------------------------------------------------- -export interface BatchWithdrawItem { +/** + * A single stream to create as part of a batch creation. + * + * This is the strongly typed counterpart of the loosely-shaped objects that + * batch creation used to accept: `recipient`, `token` and `depositAmount` are + * required, so a missing recipient or a misformatted amount is caught by the + * compiler instead of at runtime. + * + * @example + * ```ts + * const payloads: BatchStreamCreationPayload[] = [ + * { recipient: 'G...', token: 'native', depositAmount: '100' }, + * ]; + * await client.streams.createBatchStreams(payloads); + * ``` + */ +export interface BatchStreamCreationPayload { + /** Stellar recipient address. */ + recipient: string; + /** 'native' (XLM), 'USDC', or a contract address. */ + token: string; + /** Total deposit in display units (e.g. '1000'). */ + depositAmount: string; + /** Stream duration in seconds (mutually exclusive with `ratePerSecond`). */ + durationSeconds?: number; + /** Override rate in stroops/s (mutually exclusive with `durationSeconds`). */ + ratePerSecond?: string; + /** Unix timestamp; defaults to current ledger time. */ + startTime?: number; + /** Optional stop/end time (Unix timestamp). */ + stopTime?: number; + /** Optional stop/end time alias (Unix timestamp). */ + endTime?: number; + /** Optional sender address. */ + sender?: string; + /** Whether the sender can claw back unstreamed tokens. */ + clawbackEnabled?: boolean; +} + +/** + * A single withdrawal to perform as part of a batch withdraw. + * + * `streamId` is required, so a batch can no longer contain an entry that + * silently targets nothing. + * + * @example + * ```ts + * const payloads: BatchWithdrawPayload[] = [{ streamId: 1n }]; + * await client.streams.batchWithdraw(payloads); + * ``` + */ +export interface BatchWithdrawPayload { + /** The stream to withdraw from. */ streamId: bigint | string; /** Amount in stroops. Defaults to the full withdrawable balance if omitted. */ amount?: bigint; } +/** + * Configuration for a single stream in a batch creation. + * + * Alias of {@link BatchStreamCreationPayload}, kept for backwards + * compatibility with existing callers. + */ +export type StreamConfig = BatchStreamCreationPayload; + +export interface BatchWithdrawItem extends BatchWithdrawPayload {} + export interface BatchWithdrawResult { streamId: bigint; success: boolean;