From 37d41dfddd20f21c7471dd41208c025f1af0b749 Mon Sep 17 00:00:00 2001 From: Cameron Marshall Date: Mon, 4 May 2026 12:41:31 -0400 Subject: [PATCH 1/2] Add typed error classes for transaction lifecycle: broadcast, proving, timeout, finalize, and duplicate errors --- .../src/actions/wallet/simulateContract.ts | 7 +- packages/core/src/errors/errors.ts | 173 +++++++++++++++ packages/core/src/index.ts | 10 + packages/core/test/errors/errors.test.ts | 204 ++++++++++++++++++ packages/provable/src/index.ts | 108 ++++++---- 5 files changed, 458 insertions(+), 44 deletions(-) diff --git a/packages/core/src/actions/wallet/simulateContract.ts b/packages/core/src/actions/wallet/simulateContract.ts index ccc5348f..17a59c6d 100644 --- a/packages/core/src/actions/wallet/simulateContract.ts +++ b/packages/core/src/actions/wallet/simulateContract.ts @@ -1,4 +1,4 @@ -import { AccountNotFoundError, ProvingNotConfiguredError } from '../../errors/errors.js' +import { AccountNotFoundError, ProvingNotConfiguredError, SimulateNotSupportedError } from '../../errors/errors.js' import type { Client } from '../../clients/createClient.js' export type SimulateContractParameters = { @@ -31,10 +31,7 @@ export async function simulateContract( } if (account.type === 'rpc') { - throw new Error( - 'simulateContract is not available for RPC (wallet) accounts. ' + - 'Wallets do not expose a local dry-run interface. Use executeContract instead.', - ) + throw new SimulateNotSupportedError() } if (account.type === 'local') { diff --git a/packages/core/src/errors/errors.ts b/packages/core/src/errors/errors.ts index f37012c8..a411ca47 100644 --- a/packages/core/src/errors/errors.ts +++ b/packages/core/src/errors/errors.ts @@ -76,3 +76,176 @@ export class TransactionHistoryNotSupportedError extends BaseError { this.name = 'TransactionHistoryNotSupportedError' } } + +// ── Transaction lifecycle errors ───────────────────────────────────── + +export class InvalidTransactionError extends BaseError { + constructor(message: string, options?: ErrorOptions) { + super( + `Invalid transaction: ${message}. ` + + 'Verify that the transaction is well-formed and inputs are valid.', + options, + ) + this.name = 'InvalidTransactionError' + } +} + +export class DuplicateTransactionError extends BaseError { + readonly transactionId?: string + + constructor(transactionId?: string, options?: ErrorOptions) { + super( + `Transaction${transactionId ? ` ${transactionId}` : ''} already exists in the ledger. ` + + 'This transaction has already been submitted. ' + + 'If you intended a new transaction, ensure the inputs differ.', + options, + ) + this.name = 'DuplicateTransactionError' + this.transactionId = transactionId + } +} + +export class RecordAlreadyUsedError extends BaseError { + constructor(message: string, options?: ErrorOptions) { + super( + `Record already consumed: ${message}. ` + + 'A record input has already been spent in another transaction. ' + + 'Fetch fresh records with requestRecords() before retrying.', + options, + ) + this.name = 'RecordAlreadyUsedError' + } +} + +export class BroadcastError extends BaseError { + readonly statusCode?: number + + constructor(message: string, statusCode?: number, options?: ErrorOptions) { + super( + `Transaction broadcast failed${statusCode ? ` (HTTP ${statusCode})` : ''}: ${message}. ` + + 'The network may be congested. Retry after a short delay.', + options, + ) + this.name = 'BroadcastError' + this.statusCode = statusCode + } +} + +export class TransactionTimeoutError extends BaseError { + readonly transactionId: string + readonly timeoutMs: number + + constructor(transactionId: string, timeoutMs: number, options?: ErrorOptions) { + super( + `Transaction ${transactionId} not confirmed within ${timeoutMs / 1000}s. ` + + 'The transaction may still be pending — check its status with getTransaction() ' + + 'before resubmitting to avoid a DuplicateTransactionError.', + options, + ) + this.name = 'TransactionTimeoutError' + this.transactionId = transactionId + this.timeoutMs = timeoutMs + } +} + +export class FinalizeRevertError extends BaseError { + readonly transactionId: string + + constructor(transactionId: string, options?: ErrorOptions) { + super( + `Transaction ${transactionId} was rejected — the finalize block reverted on-chain. ` + + 'The base fee has been consumed. Check that on-chain state (mappings, balances) ' + + 'still matches your expectations and retry with fresh inputs.', + options, + ) + this.name = 'FinalizeRevertError' + this.transactionId = transactionId + } +} + +export class ProvingError extends BaseError { + readonly statusCode?: number + + constructor(message: string, statusCode?: number, options?: ErrorOptions) { + super( + `Proof generation failed${statusCode ? ` (HTTP ${statusCode})` : ''}: ${message}. ` + + 'If using delegated proving, check the prover service status. ' + + 'For local proving, ensure sufficient memory and valid program inputs.', + options, + ) + this.name = 'ProvingError' + this.statusCode = statusCode + } +} + +export class SimulateNotSupportedError extends BaseError { + constructor() { + super( + 'simulateContract is not available for RPC (wallet) accounts. ' + + 'Wallets do not expose a local dry-run interface. ' + + 'Use executeContract for on-chain execution, or switch to a local account for simulation.', + ) + this.name = 'SimulateNotSupportedError' + } +} + +// ── Error classification ───────────────────────────────────────────── + +/** + * Classify a raw SDK error from submitTransaction or submitProvingRequest + * into a typed Veil error. Parses error messages since submitTransaction + * does not preserve HTTP status codes. + */ +export function classifyBroadcastError( + error: unknown, + transactionId?: string, +): InvalidTransactionError | DuplicateTransactionError | RecordAlreadyUsedError | BroadcastError { + const message = error instanceof Error ? error.message : String(error) + const status = (error as any)?.status as number | undefined + + if (/already exists in the ledger/i.test(message)) { + return new DuplicateTransactionError(transactionId, { cause: error as Error }) + } + + if ( + /duplicate/i.test(message) && + /output id|input id|commitment|nonce|serial.?number/i.test(message) + ) { + return new RecordAlreadyUsedError(message, { cause: error as Error }) + } + + if ( + status === 400 || status === 422 || + /invalid transaction/i.test(message) || + /not well-formed/i.test(message) || + /incorrect transaction id/i.test(message) || + /fee verification failed/i.test(message) + ) { + return new InvalidTransactionError(message, { cause: error as Error }) + } + + return new BroadcastError(message, status, { cause: error as Error }) +} + +/** + * Classify a raw SDK error from proof generation or DPS submission. + * Delegates to classifyBroadcastError if the message looks like a + * broadcast failure (DPS surfaces broadcast errors through its response). + */ +export function classifyProvingError( + error: unknown, +): ProvingError | InvalidTransactionError | DuplicateTransactionError | RecordAlreadyUsedError | BroadcastError { + const message = error instanceof Error ? error.message : String(error) + const status = (error as any)?.status as number | undefined + + if ( + /already exists/i.test(message) || + /duplicate.*(?:output id|input id|commitment|nonce|serial)/i.test(message) || + /invalid transaction/i.test(message) || + /not well-formed/i.test(message) + ) { + return classifyBroadcastError(error) + } + + return new ProvingError(message, status, { cause: error as Error }) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 07eb852c..355f6ef5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -97,6 +97,16 @@ export { ProgramNotFoundError, InvalidInputError, TransactionHistoryNotSupportedError, + InvalidTransactionError, + DuplicateTransactionError, + RecordAlreadyUsedError, + BroadcastError, + TransactionTimeoutError, + FinalizeRevertError, + ProvingError, + SimulateNotSupportedError, + classifyBroadcastError, + classifyProvingError, } from './errors/errors.js' // Utils diff --git a/packages/core/test/errors/errors.test.ts b/packages/core/test/errors/errors.test.ts index e3ea0d8a..1b0c3054 100644 --- a/packages/core/test/errors/errors.test.ts +++ b/packages/core/test/errors/errors.test.ts @@ -7,11 +7,22 @@ import { ProvingNotConfiguredError, TransportError, BaseError, + InvalidTransactionError, + DuplicateTransactionError, + RecordAlreadyUsedError, + BroadcastError, + TransactionTimeoutError, + FinalizeRevertError, + ProvingError, + SimulateNotSupportedError, + classifyBroadcastError, + classifyProvingError, } from '../../src/errors/errors.js' import { writeContract } from '../../src/actions/wallet/writeContract.js' import { deployContract } from '../../src/actions/wallet/deployContract.js' import { signMessage } from '../../src/actions/wallet/signMessage.js' import { decrypt } from '../../src/actions/wallet/decrypt.js' +import { simulateContract } from '../../src/actions/wallet/simulateContract.js' describe('error classes', () => { it('AccountNotFoundError has actionable message with code example', () => { @@ -126,4 +137,197 @@ describe('error paths in wallet actions', () => { cipherText: 'record1...', })).rejects.toThrow(AccountNotFoundError) }) + + it('simulateContract throws SimulateNotSupportedError for RPC account', async () => { + const client = { + account: { type: 'rpc', address: 'aleo1abc', sign: vi.fn() }, + request: vi.fn(), + } as any + + await expect(simulateContract(client, { + program: 'token.aleo', + function: 'mint', + inputs: [], + })).rejects.toThrow(SimulateNotSupportedError) + }) +}) + +describe('typed transaction errors', () => { + it('InvalidTransactionError includes message and is BaseError', () => { + const err = new InvalidTransactionError('Fee verification failed: insufficient balance') + expect(err.name).toBe('InvalidTransactionError') + expect(err.message).toContain('Fee verification failed') + expect(err.message).toContain('well-formed') + expect(err).toBeInstanceOf(BaseError) + }) + + it('DuplicateTransactionError includes transactionId', () => { + const err = new DuplicateTransactionError('at1abc') + expect(err.name).toBe('DuplicateTransactionError') + expect(err.transactionId).toBe('at1abc') + expect(err.message).toContain('at1abc') + expect(err.message).toContain('already exists') + expect(err).toBeInstanceOf(BaseError) + }) + + it('DuplicateTransactionError works without transactionId', () => { + const err = new DuplicateTransactionError() + expect(err.transactionId).toBeUndefined() + expect(err.message).toContain('already exists') + }) + + it('RecordAlreadyUsedError includes message', () => { + const err = new RecordAlreadyUsedError('Found a duplicate Output ID') + expect(err.name).toBe('RecordAlreadyUsedError') + expect(err.message).toContain('duplicate Output ID') + expect(err.message).toContain('requestRecords') + expect(err).toBeInstanceOf(BaseError) + }) + + it('BroadcastError includes statusCode', () => { + const err = new BroadcastError('service unavailable', 503) + expect(err.name).toBe('BroadcastError') + expect(err.statusCode).toBe(503) + expect(err.message).toContain('503') + expect(err.message).toContain('congested') + expect(err).toBeInstanceOf(BaseError) + }) + + it('BroadcastError works without statusCode', () => { + const err = new BroadcastError('unknown failure') + expect(err.statusCode).toBeUndefined() + expect(err.message).toContain('unknown failure') + }) + + it('TransactionTimeoutError includes txId and timeout', () => { + const err = new TransactionTimeoutError('at1xyz', 300_000) + expect(err.name).toBe('TransactionTimeoutError') + expect(err.transactionId).toBe('at1xyz') + expect(err.timeoutMs).toBe(300_000) + expect(err.message).toContain('at1xyz') + expect(err.message).toContain('300s') + expect(err.message).toContain('getTransaction') + expect(err).toBeInstanceOf(BaseError) + }) + + it('FinalizeRevertError includes txId', () => { + const err = new FinalizeRevertError('at1def') + expect(err.name).toBe('FinalizeRevertError') + expect(err.transactionId).toBe('at1def') + expect(err.message).toContain('at1def') + expect(err.message).toContain('reverted') + expect(err.message).toContain('fee has been consumed') + expect(err).toBeInstanceOf(BaseError) + }) + + it('ProvingError includes statusCode', () => { + const err = new ProvingError('WASM out of memory', 500) + expect(err.name).toBe('ProvingError') + expect(err.statusCode).toBe(500) + expect(err.message).toContain('WASM out of memory') + expect(err.message).toContain('500') + expect(err).toBeInstanceOf(BaseError) + }) + + it('SimulateNotSupportedError has actionable message', () => { + const err = new SimulateNotSupportedError() + expect(err.name).toBe('SimulateNotSupportedError') + expect(err.message).toContain('RPC') + expect(err.message).toContain('executeContract') + expect(err).toBeInstanceOf(BaseError) + }) + + it('cause chain is preserved', () => { + const cause = new Error('underlying SDK error') + const err = new InvalidTransactionError('bad tx', { cause }) + expect(err.cause).toBe(cause) + }) +}) + +describe('classifyBroadcastError', () => { + it('classifies "already exists" as DuplicateTransactionError', () => { + const raw = new Error("Transaction 'at1abc' already exists in the ledger") + const err = classifyBroadcastError(raw, 'at1abc') + expect(err).toBeInstanceOf(DuplicateTransactionError) + expect((err as DuplicateTransactionError).transactionId).toBe('at1abc') + expect(err.cause).toBe(raw) + }) + + it('classifies "duplicate Output ID" as RecordAlreadyUsedError', () => { + const raw = new Error('Found a duplicate Output ID in the transaction') + const err = classifyBroadcastError(raw) + expect(err).toBeInstanceOf(RecordAlreadyUsedError) + }) + + it('classifies "duplicate serial_number" as RecordAlreadyUsedError', () => { + const raw = new Error('Found a duplicate serial_number in the transaction') + const err = classifyBroadcastError(raw) + expect(err).toBeInstanceOf(RecordAlreadyUsedError) + }) + + it('classifies "Invalid transaction" as InvalidTransactionError', () => { + const raw = new Error('Invalid transaction — Fee verification failed: insufficient balance') + const err = classifyBroadcastError(raw) + expect(err).toBeInstanceOf(InvalidTransactionError) + }) + + it('classifies "not well-formed" as InvalidTransactionError', () => { + const raw = new Error("Transaction 'at1abc' is not well-formed: bad inputs") + const err = classifyBroadcastError(raw) + expect(err).toBeInstanceOf(InvalidTransactionError) + }) + + it('classifies HTTP 400 as InvalidTransactionError', () => { + const raw = Object.assign(new Error('some message'), { status: 400 }) + const err = classifyBroadcastError(raw) + expect(err).toBeInstanceOf(InvalidTransactionError) + }) + + it('classifies HTTP 422 as InvalidTransactionError', () => { + const raw = Object.assign(new Error('some message'), { status: 422 }) + const err = classifyBroadcastError(raw) + expect(err).toBeInstanceOf(InvalidTransactionError) + }) + + it('falls back to BroadcastError for unrecognized messages', () => { + const raw = new Error('something unexpected happened') + const err = classifyBroadcastError(raw) + expect(err).toBeInstanceOf(BroadcastError) + expect(err.cause).toBe(raw) + }) + + it('BroadcastError captures HTTP 503 status', () => { + const raw = Object.assign(new Error('service unavailable'), { status: 503 }) + const err = classifyBroadcastError(raw) + expect(err).toBeInstanceOf(BroadcastError) + expect((err as BroadcastError).statusCode).toBe(503) + }) +}) + +describe('classifyProvingError', () => { + it('classifies generic proving failure as ProvingError', () => { + const raw = new Error('WASM execution failed: out of memory') + const err = classifyProvingError(raw) + expect(err).toBeInstanceOf(ProvingError) + expect(err.cause).toBe(raw) + }) + + it('delegates broadcast-like messages to classifyBroadcastError', () => { + const raw = new Error("Transaction 'at1abc' already exists in the ledger") + const err = classifyProvingError(raw) + expect(err).toBeInstanceOf(DuplicateTransactionError) + }) + + it('delegates "Invalid transaction" to classifyBroadcastError', () => { + const raw = new Error('Invalid transaction — bad proof') + const err = classifyProvingError(raw) + expect(err).toBeInstanceOf(InvalidTransactionError) + }) + + it('preserves HTTP status on ProvingError', () => { + const raw = Object.assign(new Error('prover service down'), { status: 500 }) + const err = classifyProvingError(raw) + expect(err).toBeInstanceOf(ProvingError) + expect((err as ProvingError).statusCode).toBe(500) + }) }) diff --git a/packages/provable/src/index.ts b/packages/provable/src/index.ts index 14305f2c..40f423c1 100644 --- a/packages/provable/src/index.ts +++ b/packages/provable/src/index.ts @@ -29,6 +29,12 @@ import { createPublicClient, createWalletClient, http, + BaseError, + TransactionTimeoutError, + FinalizeRevertError, + ProvingError, + classifyBroadcastError, + classifyProvingError, } from '@veil/core' import { mnemonicToHDKey, type AleoDerivationId } from './mnemonic.js' @@ -356,65 +362,89 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo return outputs } - /** Poll for transaction confirmation */ + /** Poll for confirmed transaction and check finalize status */ async function waitForConfirmation(txId: string): Promise { const pollingClient = new AleoNetworkClient(networkUrl) const timeout = options.confirmationTimeout ?? 300_000 const startTime = Date.now() while (Date.now() - startTime < timeout) { try { - const tx = await pollingClient.getTransaction(txId) - if (tx) return tx - } catch { + const confirmed = await pollingClient.getConfirmedTransaction(txId) + if (confirmed) { + if (confirmed.status === 'rejected') { + throw new FinalizeRevertError(txId) + } + return confirmed.transaction + } + } catch (e) { + if (e instanceof FinalizeRevertError) throw e // Transaction not found yet, continue polling } await new Promise((resolve) => setTimeout(resolve, 5_000)) } - throw new Error(`Transaction ${txId} not confirmed within ${timeout / 1000}s`) + throw new TransactionTimeoutError(txId, timeout) } if (options.mode === 'delegated') { - if (!options.proverUrl) throw new Error('Delegated execution requires proverUrl') - - const provingRequest = await programManager.provingRequest({ - programName: execOptions.programName, - programSource: execOptions.programSource, - programImports: execOptions.programImports, - functionName: execOptions.functionName, - inputs: execOptions.inputs, - priorityFee, - privateFee: execOptions.privateFee ?? false, - broadcast: true, - }) - - const dpsClient = new AleoNetworkClient(options.proverUrl) - const response = await dpsClient.submitProvingRequest({ - provingRequest, - url: options.proverUrl, - apiKey: options.apiKey, - consumerId: options.consumerId, - }) + if (!options.proverUrl) throw new ProvingError('Delegated execution requires proverUrl') + + let response: any + try { + const provingRequest = await programManager.provingRequest({ + programName: execOptions.programName, + programSource: execOptions.programSource, + programImports: execOptions.programImports, + functionName: execOptions.functionName, + inputs: execOptions.inputs, + priorityFee, + privateFee: execOptions.privateFee ?? false, + broadcast: true, + }) + + const dpsClient = new AleoNetworkClient(options.proverUrl) + response = await dpsClient.submitProvingRequest({ + provingRequest, + url: options.proverUrl, + apiKey: options.apiKey, + consumerId: options.consumerId, + }) + } catch (e) { + if (e instanceof BaseError) throw e + throw classifyProvingError(e) + } const txId = response.transaction?.id - if (!txId) throw new Error('DPS response did not contain a transaction ID') + if (!txId) throw new ProvingError('DPS response did not contain a transaction ID') const confirmedTx = await waitForConfirmation(txId) return { transactionId: txId, outputs: extractOutputs(confirmedTx) } } else { - const tx = await programManager.buildExecutionTransaction({ - programName: execOptions.programName, - functionName: execOptions.functionName, - inputs: execOptions.inputs, - priorityFee, - privateFee: execOptions.privateFee ?? false, - program: execOptions.programSource, - imports: execOptions.programImports, - }) - - const submitClient = new AleoNetworkClient(networkUrl) - submitClient.setVerboseErrors(false) - const txId = await submitClient.submitTransaction(tx) + let tx: any + try { + tx = await programManager.buildExecutionTransaction({ + programName: execOptions.programName, + functionName: execOptions.functionName, + inputs: execOptions.inputs, + priorityFee, + privateFee: execOptions.privateFee ?? false, + program: execOptions.programSource, + imports: execOptions.programImports, + }) + } catch (e) { + if (e instanceof BaseError) throw e + throw new ProvingError(e instanceof Error ? e.message : String(e), undefined, { cause: e as Error }) + } + + let txId: string + try { + const submitClient = new AleoNetworkClient(networkUrl) + submitClient.setVerboseErrors(false) + txId = await submitClient.submitTransaction(tx) + } catch (e) { + if (e instanceof BaseError) throw e + throw classifyBroadcastError(e) + } const confirmedTx = await waitForConfirmation(txId) return { transactionId: txId, outputs: extractOutputs(confirmedTx) } From 2dd4bfa0d818bff8ff603215cf1bd91a4cfe9aa8 Mon Sep 17 00:00:00 2001 From: Cameron Marshall Date: Wed, 6 May 2026 15:45:43 -0400 Subject: [PATCH 2/2] Address review feedback: options bag constructors, split record errors, ConfigurationError, lastError tracking, getStatus helper --- packages/core/src/errors/errors.ts | 86 +++++++++++++------ packages/core/src/index.ts | 4 +- packages/core/test/errors/errors.test.ts | 49 ++++++++--- packages/provable/src/index.ts | 12 +-- .../provable/test/execute.integration.test.ts | 8 ++ 5 files changed, 112 insertions(+), 47 deletions(-) diff --git a/packages/core/src/errors/errors.ts b/packages/core/src/errors/errors.ts index a411ca47..cdb4df98 100644 --- a/packages/core/src/errors/errors.ts +++ b/packages/core/src/errors/errors.ts @@ -105,29 +105,43 @@ export class DuplicateTransactionError extends BaseError { } } -export class RecordAlreadyUsedError extends BaseError { +/** A record was double-spent — the serial number has already been consumed */ +export class RecordSpentError extends BaseError { constructor(message: string, options?: ErrorOptions) { super( - `Record already consumed: ${message}. ` + - 'A record input has already been spent in another transaction. ' + + `Record already spent: ${message}. ` + + 'This record\'s serial number has been consumed in another transaction. ' + 'Fetch fresh records with requestRecords() before retrying.', options, ) - this.name = 'RecordAlreadyUsedError' + this.name = 'RecordSpentError' + } +} + +/** A record output ID collision — typically a program bug, not user-recoverable */ +export class OutputIdCollisionError extends BaseError { + constructor(message: string, options?: ErrorOptions) { + super( + `Output ID collision: ${message}. ` + + 'A record output produced by this transaction has a duplicate identifier. ' + + 'This is typically a program-level issue, not a double-spend.', + options, + ) + this.name = 'OutputIdCollisionError' } } export class BroadcastError extends BaseError { readonly statusCode?: number - constructor(message: string, statusCode?: number, options?: ErrorOptions) { + constructor(opts: { message: string; statusCode?: number; cause?: Error }) { super( - `Transaction broadcast failed${statusCode ? ` (HTTP ${statusCode})` : ''}: ${message}. ` + + `Transaction broadcast failed${opts.statusCode ? ` (HTTP ${opts.statusCode})` : ''}: ${opts.message}. ` + 'The network may be congested. Retry after a short delay.', - options, + opts.cause ? { cause: opts.cause } : undefined, ) this.name = 'BroadcastError' - this.statusCode = statusCode + this.statusCode = opts.statusCode } } @@ -135,16 +149,16 @@ export class TransactionTimeoutError extends BaseError { readonly transactionId: string readonly timeoutMs: number - constructor(transactionId: string, timeoutMs: number, options?: ErrorOptions) { + constructor(opts: { transactionId: string; timeoutMs: number; cause?: Error }) { super( - `Transaction ${transactionId} not confirmed within ${timeoutMs / 1000}s. ` + + `Transaction ${opts.transactionId} not confirmed within ${opts.timeoutMs / 1000}s. ` + 'The transaction may still be pending — check its status with getTransaction() ' + 'before resubmitting to avoid a DuplicateTransactionError.', - options, + opts.cause ? { cause: opts.cause } : undefined, ) this.name = 'TransactionTimeoutError' - this.transactionId = transactionId - this.timeoutMs = timeoutMs + this.transactionId = opts.transactionId + this.timeoutMs = opts.timeoutMs } } @@ -166,15 +180,23 @@ export class FinalizeRevertError extends BaseError { export class ProvingError extends BaseError { readonly statusCode?: number - constructor(message: string, statusCode?: number, options?: ErrorOptions) { + constructor(opts: { message: string; statusCode?: number; cause?: Error }) { super( - `Proof generation failed${statusCode ? ` (HTTP ${statusCode})` : ''}: ${message}. ` + + `Proof generation failed${opts.statusCode ? ` (HTTP ${opts.statusCode})` : ''}: ${opts.message}. ` + 'If using delegated proving, check the prover service status. ' + 'For local proving, ensure sufficient memory and valid program inputs.', - options, + opts.cause ? { cause: opts.cause } : undefined, ) this.name = 'ProvingError' - this.statusCode = statusCode + this.statusCode = opts.statusCode + } +} + +/** Configuration error — missing required options, not a proving or broadcast failure */ +export class ConfigurationError extends BaseError { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'ConfigurationError' } } @@ -191,6 +213,13 @@ export class SimulateNotSupportedError extends BaseError { // ── Error classification ───────────────────────────────────────────── +/** Safely extract HTTP status from an unknown error object */ +function getStatus(e: unknown): number | undefined { + return typeof e === 'object' && e !== null && 'status' in e && typeof (e as any).status === 'number' + ? (e as any).status + : undefined +} + /** * Classify a raw SDK error from submitTransaction or submitProvingRequest * into a typed Veil error. Parses error messages since submitTransaction @@ -199,19 +228,20 @@ export class SimulateNotSupportedError extends BaseError { export function classifyBroadcastError( error: unknown, transactionId?: string, -): InvalidTransactionError | DuplicateTransactionError | RecordAlreadyUsedError | BroadcastError { +): InvalidTransactionError | DuplicateTransactionError | RecordSpentError | OutputIdCollisionError | BroadcastError { const message = error instanceof Error ? error.message : String(error) - const status = (error as any)?.status as number | undefined + const status = getStatus(error) if (/already exists in the ledger/i.test(message)) { return new DuplicateTransactionError(transactionId, { cause: error as Error }) } - if ( - /duplicate/i.test(message) && - /output id|input id|commitment|nonce|serial.?number/i.test(message) - ) { - return new RecordAlreadyUsedError(message, { cause: error as Error }) + // Distinguish double-spend (serial number) from output ID collision + if (/duplicate/i.test(message) && /serial.?number/i.test(message)) { + return new RecordSpentError(message, { cause: error as Error }) + } + if (/duplicate/i.test(message) && /output id|input id|commitment|nonce/i.test(message)) { + return new OutputIdCollisionError(message, { cause: error as Error }) } if ( @@ -224,7 +254,7 @@ export function classifyBroadcastError( return new InvalidTransactionError(message, { cause: error as Error }) } - return new BroadcastError(message, status, { cause: error as Error }) + return new BroadcastError({ message, statusCode: status, cause: error as Error }) } /** @@ -234,9 +264,9 @@ export function classifyBroadcastError( */ export function classifyProvingError( error: unknown, -): ProvingError | InvalidTransactionError | DuplicateTransactionError | RecordAlreadyUsedError | BroadcastError { +): ProvingError | InvalidTransactionError | DuplicateTransactionError | RecordSpentError | OutputIdCollisionError | BroadcastError { const message = error instanceof Error ? error.message : String(error) - const status = (error as any)?.status as number | undefined + const status = getStatus(error) if ( /already exists/i.test(message) || @@ -247,5 +277,5 @@ export function classifyProvingError( return classifyBroadcastError(error) } - return new ProvingError(message, status, { cause: error as Error }) + return new ProvingError({ message, statusCode: status, cause: error as Error }) } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 355f6ef5..7fb8b204 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -99,11 +99,13 @@ export { TransactionHistoryNotSupportedError, InvalidTransactionError, DuplicateTransactionError, - RecordAlreadyUsedError, + RecordSpentError, + OutputIdCollisionError, BroadcastError, TransactionTimeoutError, FinalizeRevertError, ProvingError, + ConfigurationError, SimulateNotSupportedError, classifyBroadcastError, classifyProvingError, diff --git a/packages/core/test/errors/errors.test.ts b/packages/core/test/errors/errors.test.ts index 1b0c3054..d5e1d746 100644 --- a/packages/core/test/errors/errors.test.ts +++ b/packages/core/test/errors/errors.test.ts @@ -9,11 +9,13 @@ import { BaseError, InvalidTransactionError, DuplicateTransactionError, - RecordAlreadyUsedError, + RecordSpentError, + OutputIdCollisionError, BroadcastError, TransactionTimeoutError, FinalizeRevertError, ProvingError, + ConfigurationError, SimulateNotSupportedError, classifyBroadcastError, classifyProvingError, @@ -176,16 +178,31 @@ describe('typed transaction errors', () => { expect(err.message).toContain('already exists') }) - it('RecordAlreadyUsedError includes message', () => { - const err = new RecordAlreadyUsedError('Found a duplicate Output ID') - expect(err.name).toBe('RecordAlreadyUsedError') - expect(err.message).toContain('duplicate Output ID') + it('RecordSpentError includes message', () => { + const err = new RecordSpentError('Found a duplicate serial_number') + expect(err.name).toBe('RecordSpentError') + expect(err.message).toContain('serial_number') expect(err.message).toContain('requestRecords') expect(err).toBeInstanceOf(BaseError) }) + it('OutputIdCollisionError includes message', () => { + const err = new OutputIdCollisionError('Found a duplicate Output ID') + expect(err.name).toBe('OutputIdCollisionError') + expect(err.message).toContain('Output ID') + expect(err.message).toContain('program-level') + expect(err).toBeInstanceOf(BaseError) + }) + + it('ConfigurationError includes message', () => { + const err = new ConfigurationError('Delegated execution requires proverUrl') + expect(err.name).toBe('ConfigurationError') + expect(err.message).toContain('proverUrl') + expect(err).toBeInstanceOf(BaseError) + }) + it('BroadcastError includes statusCode', () => { - const err = new BroadcastError('service unavailable', 503) + const err = new BroadcastError({ message: 'service unavailable', statusCode: 503 }) expect(err.name).toBe('BroadcastError') expect(err.statusCode).toBe(503) expect(err.message).toContain('503') @@ -194,13 +211,13 @@ describe('typed transaction errors', () => { }) it('BroadcastError works without statusCode', () => { - const err = new BroadcastError('unknown failure') + const err = new BroadcastError({ message: 'unknown failure' }) expect(err.statusCode).toBeUndefined() expect(err.message).toContain('unknown failure') }) it('TransactionTimeoutError includes txId and timeout', () => { - const err = new TransactionTimeoutError('at1xyz', 300_000) + const err = new TransactionTimeoutError({ transactionId: 'at1xyz', timeoutMs: 300_000 }) expect(err.name).toBe('TransactionTimeoutError') expect(err.transactionId).toBe('at1xyz') expect(err.timeoutMs).toBe(300_000) @@ -221,7 +238,7 @@ describe('typed transaction errors', () => { }) it('ProvingError includes statusCode', () => { - const err = new ProvingError('WASM out of memory', 500) + const err = new ProvingError({ message: 'WASM out of memory', statusCode: 500 }) expect(err.name).toBe('ProvingError') expect(err.statusCode).toBe(500) expect(err.message).toContain('WASM out of memory') @@ -253,16 +270,22 @@ describe('classifyBroadcastError', () => { expect(err.cause).toBe(raw) }) - it('classifies "duplicate Output ID" as RecordAlreadyUsedError', () => { + it('classifies "duplicate Output ID" as OutputIdCollisionError', () => { const raw = new Error('Found a duplicate Output ID in the transaction') const err = classifyBroadcastError(raw) - expect(err).toBeInstanceOf(RecordAlreadyUsedError) + expect(err).toBeInstanceOf(OutputIdCollisionError) }) - it('classifies "duplicate serial_number" as RecordAlreadyUsedError', () => { + it('classifies "duplicate serial_number" as RecordSpentError', () => { const raw = new Error('Found a duplicate serial_number in the transaction') const err = classifyBroadcastError(raw) - expect(err).toBeInstanceOf(RecordAlreadyUsedError) + expect(err).toBeInstanceOf(RecordSpentError) + }) + + it('classifies "duplicate commitment" as OutputIdCollisionError', () => { + const raw = new Error('Found a duplicate commitment in the transaction') + const err = classifyBroadcastError(raw) + expect(err).toBeInstanceOf(OutputIdCollisionError) }) it('classifies "Invalid transaction" as InvalidTransactionError', () => { diff --git a/packages/provable/src/index.ts b/packages/provable/src/index.ts index 40f423c1..6c18a3ee 100644 --- a/packages/provable/src/index.ts +++ b/packages/provable/src/index.ts @@ -33,6 +33,7 @@ import { TransactionTimeoutError, FinalizeRevertError, ProvingError, + ConfigurationError, classifyBroadcastError, classifyProvingError, } from '@veil/core' @@ -367,6 +368,7 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo const pollingClient = new AleoNetworkClient(networkUrl) const timeout = options.confirmationTimeout ?? 300_000 const startTime = Date.now() + let lastError: unknown while (Date.now() - startTime < timeout) { try { const confirmed = await pollingClient.getConfirmedTransaction(txId) @@ -378,15 +380,15 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo } } catch (e) { if (e instanceof FinalizeRevertError) throw e - // Transaction not found yet, continue polling + lastError = e // capture for timeout cause } await new Promise((resolve) => setTimeout(resolve, 5_000)) } - throw new TransactionTimeoutError(txId, timeout) + throw new TransactionTimeoutError({ transactionId: txId, timeoutMs: timeout, cause: lastError as Error | undefined }) } if (options.mode === 'delegated') { - if (!options.proverUrl) throw new ProvingError('Delegated execution requires proverUrl') + if (!options.proverUrl) throw new ConfigurationError('Delegated execution requires proverUrl. Pass proverUrl to createProvingConfig or createAleoClient.') let response: any try { @@ -414,7 +416,7 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo } const txId = response.transaction?.id - if (!txId) throw new ProvingError('DPS response did not contain a transaction ID') + if (!txId) throw new ConfigurationError('DPS response did not contain a transaction ID — check prover service configuration.') const confirmedTx = await waitForConfirmation(txId) return { transactionId: txId, outputs: extractOutputs(confirmedTx) } @@ -433,7 +435,7 @@ function buildSdk(initialNetwork: SupportedNetwork, initialSdk: SdkModule): Aleo }) } catch (e) { if (e instanceof BaseError) throw e - throw new ProvingError(e instanceof Error ? e.message : String(e), undefined, { cause: e as Error }) + throw new ProvingError({ message: e instanceof Error ? e.message : String(e), cause: e as Error }) } let txId: string diff --git a/packages/provable/test/execute.integration.test.ts b/packages/provable/test/execute.integration.test.ts index add2eec4..69c71933 100644 --- a/packages/provable/test/execute.integration.test.ts +++ b/packages/provable/test/execute.integration.test.ts @@ -11,6 +11,14 @@ * ALEO_DPS_API_KEY, ALEO_CONSUMER_ID (and optionally ALEO_DPS_URL) * * Skipped by default in CI / normal test runs. + * + * TODO: Error classifier validation against real SnarkOS responses. + * The classifyBroadcastError/classifyProvingError functions match on SnarkOS + * error message strings. A SnarkOS upgrade that rephrases error messages + * (e.g. "duplicate output id" → "output identifier conflict") would silently + * degrade typed errors to BroadcastError. A devnet integration harness that + * submits known-bad transactions and asserts the correct typed error class + * would catch this drift. See PR #49 review comment #6. */ import { describe, it, expect, beforeAll } from 'vitest' import { loadNetwork, type AleoSdk } from '../src/index.js'