Skip to main content

SDK error surfaces

@cloak.dev/sdk exposes two main layers:
  1. CloakError (runtime SDK operations)
  2. parseError / parseTransactionError (user-facing normalization)

CloakError

Most app flows should treat retryable === true as backoff-eligible.

Persist the change note before you report success

transact, transfer, partialWithdraw, swapUtxo and swapWithChange all return result.outputUtxos, and the change note among them is the user’s remaining shielded balance. Its blinding is fresh randomness generated inside the SDK and is written nowhere on chain, so a change note whose only copy you dropped is unspendable by anyone, permanently. No rescan brings it back. Outputs with amount === 0n are padding and can be skipped. fullWithdraw leaves nothing shielded only when you hand it one or two notes. With three or more it merges on chain first, and a failure part-way through those merges destroys a note that never reached you: see When fullWithdraw is not exempt. This is an error-handling concern because every example below is a try/catch. The catch is the failure path; the tail of the try is the success path, and that is where the write belongs. A try block whose tail is empty has already lost the note by the time control reaches the next line. saveNotes, dropNotes and saveSwapRefund in these snippets are your own storage, not SDK exports; Where your notes live between sessions covers what to build them on.

When fullWithdraw is not exempt

The circuit spends at most two notes per transaction, so both fullWithdraw and partialWithdraw consolidate first when you pass three or more inputs. Each consolidation round is its own on-chain transaction: it spends the two smallest notes and creates one merged note whose blinding is fresh randomness held only inside the call. onProgress fires Consolidating notes (n remaining)... once per round, and that string is the only signal a caller gets that consolidation is happening at all. Rounds that land are permanent, and the failure is what makes this an error-handling problem. If a later round or the final withdrawal throws (stale-root retries exhausted, a rejected wallet approval, a submission failure, a closed tab), then the two notes that round consumed are spent, the merged commitment is in the tree, and the call returned nothing, so there is no result.outputUtxos to write down. The merged note’s blinding went out of scope with the call. Be clear-eyed about what recovery is available: none. A viewing-key rescan with scanTransactions can show you the commitment and its amount, but the on-chain chain note carries neither the blinding nor anything that derives it, so the balance stays visible and permanently unspendable. The exemption is therefore true for one or two inputs, where no merge happens, and false for three or more. What you can actually do, in order of usefulness:
Consolidating yourself is the only option that prevents the loss rather than measuring it. Prefer it in any flow where the user can hold more than two notes.

Unauthorized and bare 401 on a send, withdraw or swap

Every request Cloak submits for you carries a signed sender, and every way that can go wrong arrives as the same opaque rejection. explainRelayAuthRejection turns one into user-facing prose, and returns null for anything that is not an authentication rejection, so it is safe to call unconditionally:
Two of the causes are not a bug in your code at all: an approval dialog left waiting, and a wrong clock on the user’s machine. Both are recoverable by retrying, and nothing was submitted for the request that was rejected. That is not the same as nothing having been submitted: with three or more inputs the rejection can arrive on a round that follows landed consolidation transactions, so reconcile with verifyUtxos before retrying rather than reusing inputUtxos. The full contract, including how to supply a signer at all, is in Request authentication.

Stale Merkle root (0x1001)

Use helpers for RootNotFound detection:
SDK note and UTXO flows already include retry loops; this usually appears only after retries are exhausted.

On-chain error message mapping

ShieldPoolErrors maps known custom program codes:

User-facing normalization

Practical retry strategy

  • Retry network and service failures with exponential backoff.
  • Avoid retrying deterministic validation failures.
  • On stale-root errors, rebuild proof data before retrying.

UTXO stale-root retry template

This template reuses inputUtxos on every attempt, which is safe only while that array holds one or two notes. With three or more, attempt 1 can land consolidation transactions before it throws, and attempt 2 then spends notes that are already nullified: the first merge round preflights them and throws UtxoAlreadySpentError instead of retrying. Consolidate down to two notes yourself before entering a loop like this, as in When fullWithdraw is not exempt. Swap partialWithdraw for transact, transfer, swapUtxo or swapWithChange and the loop is unchanged, but the tail still has to run: each of those returns a change note too, and the two swap helpers add a refund secret. fullWithdraw is the only substitution that can skip the tail, and only when it is given one or two notes, because that is when it leaves nothing shielded and performs no merges of its own.

Security logging rule

Never log full notes/UTXOs with secrets. Log only non-sensitive identifiers (commitment prefix, leaf index, request ID, and tx signature when needed for support).