Skip to content

Add typed error classes for transaction lifecycle with error classification - #49

Merged
marshacb merged 2 commits into
typesfrom
feat/typed-errors
May 8, 2026
Merged

Add typed error classes for transaction lifecycle with error classification#49
marshacb merged 2 commits into
typesfrom
feat/typed-errors

Conversation

@marshacb

@marshacb marshacb commented May 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds typed error classes for the execute lifecycle so users can programmatically distinguish between broadcast failures, proving errors, confirmation timeouts, finalize reverts, and duplicate transactions. Replaces generic Error throws throughout the execute path.

try {
  await contract.execute.mint_card({ inputs: [...] })
} catch (e) {
  if (e instanceof FinalizeRevertError) {
    // Finalize reverted on-chain - fee consumed, public state unchanged
  } else if (e instanceof TransactionTimeoutError) {
    // May still confirm - check e.transactionId before resubmitting
  } else if (e instanceof DuplicateTransactionError) {
    // Already on-chain - safe to treat as success
  } else if (e instanceof RecordAlreadyUsedError) {
    // Record spent - refresh with requestRecords() and retry
  }
}

Addresses #19 and PR #9 review comments 13, 14, 15.

New error classes (all extend BaseError)

Class Trigger
InvalidTransactionError Malformed transaction, fee verification failure, bad ID
DuplicateTransactionError Transaction already exists in the ledger
RecordAlreadyUsedError Record UTXO already consumed (duplicate Output ID, serial number, etc.)
BroadcastError Network congestion (429/503) or unrecognized broadcast failure
TransactionTimeoutError Confirmation polling exceeded timeout
FinalizeRevertError Confirmed transaction with status: "rejected" (finalize reverted, fee consumed)
ProvingError Proof generation or DPS submission failure
SimulateNotSupportedError simulateContract called on RPC (wallet) account

Error classifiers

Two functions that parse raw SDK errors into typed Veil errors:

  • classifyBroadcastError(error, txId?) - regex-based classification for submitTransaction errors (SDK doesn't preserve HTTP status)
  • classifyProvingError(error) - delegates broadcast-like messages, wraps rest as ProvingError

Execute lifecycle changes

  • waitForConfirmation now uses getConfirmedTransaction (returns status) instead of getTransaction — detects finalize reverts via status === "rejected"
  • Delegated path: provingRequest + submitProvingRequest wrapped → classifyProvingError
  • Local path: buildExecutionTransactionProvingError, submitTransactionclassifyBroadcastError
  • All catch blocks re-throw BaseError instances to avoid double-wrapping

Files

  • packages/core/src/errors/errors.ts - 8 error classes + 2 classifier functions
  • packages/core/src/index.ts - exports
  • packages/core/src/actions/wallet/simulateContract.ts - SimulateNotSupportedError
  • packages/provable/src/index.ts - execute lifecycle wrapping + getConfirmedTransaction fix
  • packages/core/test/errors/errors.test.ts - 25 new tests

Test plan

  • 614 tests pass (25 new, 0 regressions)
  • Error classes: constructor, .name, instanceof BaseError, domain properties, cause chain
  • Classifiers: all SnarkOS error message patterns correctly classified
  • SimulateNotSupportedError thrown for RPC accounts

@vercel

vercel Bot commented May 5, 2026

Copy link
Copy Markdown

@marshacb must be a member of the Provable team on Vercel to deploy.
- Click here to add @marshacb to the team.
- If you initiated this build, request access.
- If you're already a member of the Provable team, make sure that your Vercel account is connected to your GitHub account.

Learn more about collaboration on Vercel and other options here.

@marshacb
marshacb force-pushed the feat/typed-errors branch from 44fc34c to 37d41df Compare May 6, 2026 00:43
@vercel

vercel Bot commented May 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
veil-loyalty-dapp Error Error May 6, 2026 8:42pm

Request Review

@iamalwaysuncomfortable iamalwaysuncomfortable left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things worth tightening before merge.

1. waitForConfirmation swallows polling errors silently

packages/provable/src/index.ts:379-385 — the bare catch (e) discards every non-FinalizeRevertError exception (network failure, RPC down, malformed response) and keeps polling. After the timeout the caller sees TransactionTimeoutError with no hint that the actual problem was the network.

Capture the last error and attach as cause:

let lastError: unknown
while (Date.now() - startTime < timeout) {
  try {
    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
    lastError = e
  }
  await new Promise((r) => setTimeout(r, 5_000))
}
throw new TransactionTimeoutError(txId, timeout, { cause: lastError as Error | undefined })

Requires extending TransactionTimeoutError's constructor at errors.ts:134 to accept an options?: ErrorOptions arg.

2. Constructor signatures are inconsistent

errors.ts mixes (message, options?), (transactionId?, options?), (transactionId, timeoutMs, options?), and (message, statusCode?, options?) across the eight classes. Callers have to remember which class takes what in which order.

Viem's pattern — a single options bag ({ shortMessage, metaMessages, cause }) with all fields named — is what most modern SDK error hierarchies converge to. Worth adopting before public-API commitment; renaming constructor signatures after release is a breaking change.

3. (error as any)?.status casts in the classifiers

errors.ts:204 and errors.ts:239. Works because SnarkOS attaches .status on broadcast errors, but the assumption is undocumented. Replace with:

function getStatus(e: unknown): number | undefined {
  return typeof e === 'object' && e !== null && 'status' in e && typeof e.status === 'number'
    ? e.status
    : undefined
}

Reads cleaner, removes the cast, no behavior change.

4. RecordAlreadyUsedError conflates two distinct cases

errors.ts:108. The classifier regex matches both "duplicate output id" (collision in a newly-created record — basically a bug, never user-recoverable) and "duplicate serial number" (double-spend — user tried to spend the same record twice). The message says "Record already consumed," which is wrong for the output-id case.

Either broaden the message to cover both, or split into RecordSpentError (serial number) and OutputIdCollisionError (output id) since the recovery paths differ.

5. ProvingError is overloaded

provable/src/index.ts:389 ('Delegated execution requires proverUrl') and provable/src/index.ts:417 ('DPS response did not contain a transaction ID') throw ProvingError, but neither is a proof failure — the first is user config error (no proving attempted), the second is a DPS protocol violation. Suggest:

  • ConfigurationError (or MissingConfigError) for the proverUrl case.
  • DelegatedProvingError for the DPS protocol case (or keep ProvingError but add a kind: 'protocol' | 'computation' discriminant).

6. Integration tests against a real devnet

The classifiers (errors.ts:199, errors.ts:235) match SnarkOS internal error message strings. The 25 unit tests assert against the strings the classifier expects — they pass even if SnarkOS has rephrased any of them. A SnarkOS upgrade that turns "duplicate output id" into "output identifier conflict" silently degrades RecordAlreadyUsedError to BroadcastError, and instanceof RecordAlreadyUsedError checks start returning false without warning.

Worth a separate integration harness: submit known-bad transactions to a local snarkOS devnet (or replay captured broadcast responses) and assert each typed error class fires. Pure-string unit tests can't catch error-vocabulary drift; only a real devnet (or a periodically-refreshed fixture pulled from one) can.

@iamalwaysuncomfortable

Copy link
Copy Markdown
Member

The other options for waitForTransaction is to make our own functionality for it that has richer errors and NOT use the SDK's funciton.

…s, ConfigurationError, lastError tracking, getStatus helper
@marshacb

marshacb commented May 7, 2026

Copy link
Copy Markdown
Contributor Author

A few things worth tightening before merge.

1. waitForConfirmation swallows polling errors silently

packages/provable/src/index.ts:379-385 — the bare catch (e) discards every non-FinalizeRevertError exception (network failure, RPC down, malformed response) and keeps polling. After the timeout the caller sees TransactionTimeoutError with no hint that the actual problem was the network.

Capture the last error and attach as cause:

let lastError: unknown
while (Date.now() - startTime < timeout) {
  try {
    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
    lastError = e
  }
  await new Promise((r) => setTimeout(r, 5_000))
}
throw new TransactionTimeoutError(txId, timeout, { cause: lastError as Error | undefined })

Requires extending TransactionTimeoutError's constructor at errors.ts:134 to accept an options?: ErrorOptions arg.

2. Constructor signatures are inconsistent

errors.ts mixes (message, options?), (transactionId?, options?), (transactionId, timeoutMs, options?), and (message, statusCode?, options?) across the eight classes. Callers have to remember which class takes what in which order.

Viem's pattern — a single options bag ({ shortMessage, metaMessages, cause }) with all fields named — is what most modern SDK error hierarchies converge to. Worth adopting before public-API commitment; renaming constructor signatures after release is a breaking change.

3. (error as any)?.status casts in the classifiers

errors.ts:204 and errors.ts:239. Works because SnarkOS attaches .status on broadcast errors, but the assumption is undocumented. Replace with:

function getStatus(e: unknown): number | undefined {
  return typeof e === 'object' && e !== null && 'status' in e && typeof e.status === 'number'
    ? e.status
    : undefined
}

Reads cleaner, removes the cast, no behavior change.

4. RecordAlreadyUsedError conflates two distinct cases

errors.ts:108. The classifier regex matches both "duplicate output id" (collision in a newly-created record — basically a bug, never user-recoverable) and "duplicate serial number" (double-spend — user tried to spend the same record twice). The message says "Record already consumed," which is wrong for the output-id case.

Either broaden the message to cover both, or split into RecordSpentError (serial number) and OutputIdCollisionError (output id) since the recovery paths differ.

5. ProvingError is overloaded

provable/src/index.ts:389 ('Delegated execution requires proverUrl') and provable/src/index.ts:417 ('DPS response did not contain a transaction ID') throw ProvingError, but neither is a proof failure — the first is user config error (no proving attempted), the second is a DPS protocol violation. Suggest:

  • ConfigurationError (or MissingConfigError) for the proverUrl case.
  • DelegatedProvingError for the DPS protocol case (or keep ProvingError but add a kind: 'protocol' | 'computation' discriminant).

6. Integration tests against a real devnet

The classifiers (errors.ts:199, errors.ts:235) match SnarkOS internal error message strings. The 25 unit tests assert against the strings the classifier expects — they pass even if SnarkOS has rephrased any of them. A SnarkOS upgrade that turns "duplicate output id" into "output identifier conflict" silently degrades RecordAlreadyUsedError to BroadcastError, and instanceof RecordAlreadyUsedError checks start returning false without warning.

Worth a separate integration harness: submit known-bad transactions to a local snarkOS devnet (or replay captured broadcast responses) and assert each typed error class fires. Pure-string unit tests can't catch error-vocabulary drift; only a real devnet (or a periodically-refreshed fixture pulled from one) can.

All six addressed, pushed:

  • lastError tracking: waitForConfirmation captures the last polling error and attaches it as cause on TransactionTimeoutError.

  • Options bag constructors: BroadcastError, TransactionTimeoutError, and ProvingError now take { message, statusCode?, cause? } instead of positional args.

  • getStatus() helper: Replaces (error as any)?.status casts with typed extraction.

  • Split RecordAlreadyUsedErrorRecordSpentError (serial number double-spend, user-recoverable) + OutputIdCollisionError (output ID collision, program-level bug).

  • ConfigurationError: Replaces ProvingError for missing proverUrl and DPS protocol issues.

  • Devnet test harness: Opened a follow-up PR (Add devnet test harness with Leo devnode and GitHub Actions CI #53) that spawns a local Leo devnode and feeds real SnarkOS error responses through classifyBroadcastError to validate our regex patterns against actual error vocabulary. Also adds the first GitHub Actions CI workflow for the repo.

@marshacb
marshacb marked this pull request as ready for review May 8, 2026 13:14
@iamalwaysuncomfortable iamalwaysuncomfortable linked an issue May 8, 2026 that may be closed by this pull request
@marshacb
marshacb merged commit 4d33775 into types May 8, 2026
1 of 2 checks passed
iamalwaysuncomfortable added a commit that referenced this pull request May 13, 2026
* init types/tests

* adding storage variable parsing from aleo ABI

* remove extra loop for vec storage var checking

* Add contract runtime layer: record serialization, execution actions, and RecordFieldValue.type (#39)

* Add contract runtime utilities: parseRecordPlaintext, toPlaintext, encodeInputs, simulateContract, executeContract, and RecordFieldValue.type for round-trip serialization

* Add contract runtime layer: record serialization, input encoding, simulateContract, executeTransaction, and RecordFieldValue.type for round-trip support

* Rename toPlaintext to toString, scope ExecuteResult.outputs to top-level function

* Add program and recordName to RecordValue, rename toPlaintext to toString, scope ExecuteResult.outputs to top-level function

* Add ABI-aware helpers (getRecordDef, getInputTypes, ABI overloads), Leo compiler format normalization in parseAbi, and serializeRecord alias

* Implement execute lifecycle: local proving, delegated DPS, and fee handling (#46)

* Implement execute lifecycle: local proving + delegated DPS, fee conversion, confirmation polling, and end-to-end integration tests

* Use TransactionJSON from SDK instead of inline type for transaction output extraction

* Add @veil/codegen: typed contract factories with named params, typed returns, and simulate/execute proxies (#45)

* Add @veil/codegen package: generate TypeScript types, record mappers, and function I/O types from Aleo program ABIs

* Add typed contract factory generation to codegen (createXContract with embedded ABI)

* Add typed contract interface with autocomplete for function and mapping names

* Wire auto-encode inputs and auto-parse outputs into getContract proxies, accept ABI type, add comprehensive proxy tests

* Update codegen to use InputValue/ParsedOutput types matching getContract proxy signatures

* Handle unrecognized values gracefully in parseRecordPlaintextLoose

* Generate named params and typed returns in contract factory (closes Ethereum UX gap for inputs and outputs)

* Fix result.outputs reference bug and add _record support for record re-consumption in codegen

* Fix generated type errors: cast plaintext outputs through unknown, narrow abi to ABI

* Clean up generated code: remove unused imports, replace as any with indexed access types, add client validation, fix unused result in void wrappers

* Use structs for ABI detection, remove references in comments

* Re-implement simulate/execute in loadNetwork architecture, update integration tests for dynamic SDK pattern

* Add loyalty-node example app (#43)

* Add loyalty-node example app using @veil/core + @veil/provable with simulate support

* Simplify loyalty-node app to use ABI-aware utilities (no manual loadAbi, getInputTypes, or recordToString)

* Use generated contract factories with simulate proxies, add codegen dependency and generate script

* Use auto-encode/auto-parse contract proxies with native values and RecordValue inputs

* Use codegen named params and typed returns, remove manual helpers and output casts

* Add read_state demo to showcase mapping reads and complete namespace coverage

* Restructure demos around simulate/execute/read namespaces, add ALEO_PRIVATE_KEY env support

* Fix toCredits precision, use funded SDK account, simplify execute demo to single on-chain step

* Adapt loyalty-node to loadNetwork pattern, fix duplicate Input/Output exports

* Add type-level ABI inference for getContract (#48)

* Add type-level ABI inference for getContract: function/mapping name narrowing and typed namespaces

* Map future/dynamicFuture to FutureValue in codegen instead of void

* Add optional dynamicId to record types for RecordWithDynamicID and ExternalRecordWithDynamicID support

* Add typed error classes for transaction lifecycle with error classification (#49)

* Add typed error classes for transaction lifecycle: broadcast, proving, timeout, finalize, and duplicate errors

* Address review feedback: options bag constructors, split record errors, ConfigurationError, lastError tracking, getStatus helper

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: mia <93600681+miazn@users.noreply.github.com>

* Add per-transition output parsing to execute lifecycle (#54)

* Add per-transition output parsing: structured transitions from execute with program/function metadata

* Handle record_with_dynamic_id outputs in transition parser

* Add dynamicId record output test for per-transition parsing

* Align executeContract behavior across account types (#55)

- RPC path now polls for confirmation and walks transitions itself, returning
  the same {transactionId, transitions, outputs} shape as the local path. The
  SDK does not ask the wallet to decrypt — record outputs surface as raw
  record1... ciphertexts on the RPC path.
- executeTransaction aliased to writeContract everywhere (matches the Aleo
  wallet adapter spec); the full-lifecycle action lives as executeContract.
- outputs is the called function's transition only on both paths; inner
  cross-program transitions are surfaced via transitions[].
- Extract waitForConfirmation and extractTransitions to shared core utilities.
  Position-based top-level identification replaces name matching.

* Align codegen and inference integer types to bigint for all bit widths

---------

Co-authored-by: Mike Turner <mike@provable.com>

* Remove NetworkRecordProvider, use RecordScanner as sole scanning path (#56)

* Return per-transition outputs from simulateContract (#57)

* Return per-transition outputs from simulateContract

- SimulateContractReturnType (and RawSimulateResult) now carries transitions[]
  alongside outputs, matching ExecuteContractReturnType's shape. outputs is the
  called function's transition only; inner cross-program transitions are
  surfaced via transitions[].
- Contract proxy's simulate.fn() now returns { transitions, outputs } with the
  same per-transition parsing as execute.fn() — same-program transitions parsed
  with the local ABI, foreign loose-parsed.
- Provable's simulate uses programManager.buildAuthorization instead of
  programManager.run. The Authorization carries the transition list with
  outputs (same structure a confirmed Transaction has, minus the proof) and
  doesn't run a full circuit, so it's faster. Outputs flow through the shared
  extractTransitions with the local-account view-key decryptor.

* Add cross-program Authorization integration test

Surfaces a real wire-format gap: after `decryptTransition(tvk)`, `outputs(true)`
returns JS-native values (`10`) instead of Aleo-typed strings (`'10u32'`).
`transition.toString()` emits the same wire-format JSON the chain returns from
`/transaction/confirmed/{id}`, so the simulate path now JSON.parses that for
each transition before handing it to extractTransitions — outputs are
consistent with the execute path.

Test vectors (multiply_test.aleo / double_test.aleo) taken from the
@provablehq/sdk JSDoc examples.

---------

Signed-off-by: mia <93600681+miazn@users.noreply.github.com>
Co-authored-by: marshacb <cameron.marshall12@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Mike Turner <mike@provable.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expand error handling with granular SnarkOS error types

2 participants