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
7 changes: 2 additions & 5 deletions packages/core/src/actions/wallet/simulateContract.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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') {
Expand Down
203 changes: 203 additions & 0 deletions packages/core/src/errors/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,206 @@ 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
}
}

/** 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 spent: ${message}. ` +
'This record\'s serial number has been consumed in another transaction. ' +
'Fetch fresh records with requestRecords() before retrying.',
options,
)
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(opts: { message: string; statusCode?: number; cause?: Error }) {
super(
`Transaction broadcast failed${opts.statusCode ? ` (HTTP ${opts.statusCode})` : ''}: ${opts.message}. ` +
'The network may be congested. Retry after a short delay.',
opts.cause ? { cause: opts.cause } : undefined,
)
this.name = 'BroadcastError'
this.statusCode = opts.statusCode
}
}

export class TransactionTimeoutError extends BaseError {
readonly transactionId: string
readonly timeoutMs: number

constructor(opts: { transactionId: string; timeoutMs: number; cause?: Error }) {
super(
`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.',
opts.cause ? { cause: opts.cause } : undefined,
)
this.name = 'TransactionTimeoutError'
this.transactionId = opts.transactionId
this.timeoutMs = opts.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(opts: { message: string; statusCode?: number; cause?: Error }) {
super(
`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.',
opts.cause ? { cause: opts.cause } : undefined,
)
this.name = 'ProvingError'
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'
}
}

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 ─────────────────────────────────────────────

/** 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
* does not preserve HTTP status codes.
*/
export function classifyBroadcastError(
error: unknown,
transactionId?: string,
): InvalidTransactionError | DuplicateTransactionError | RecordSpentError | OutputIdCollisionError | BroadcastError {
const message = error instanceof Error ? error.message : String(error)
const status = getStatus(error)

if (/already exists in the ledger/i.test(message)) {
return new DuplicateTransactionError(transactionId, { 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 (
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, statusCode: 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 | RecordSpentError | OutputIdCollisionError | BroadcastError {
const message = error instanceof Error ? error.message : String(error)
const status = getStatus(error)

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, statusCode: status, cause: error as Error })
}
12 changes: 12 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ export {
ProgramNotFoundError,
InvalidInputError,
TransactionHistoryNotSupportedError,
InvalidTransactionError,
DuplicateTransactionError,
RecordSpentError,
OutputIdCollisionError,
BroadcastError,
TransactionTimeoutError,
FinalizeRevertError,
ProvingError,
ConfigurationError,
SimulateNotSupportedError,
classifyBroadcastError,
classifyProvingError,
} from './errors/errors.js'

// Utils
Expand Down
Loading