SDK error surfaces
@cloak.dev/sdk exposes two main layers:
CloakError(runtime SDK operations)parseError/parseTransactionError(user-facing normalization)
CloakError
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:
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:
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:
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
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.