Every way the program says no, and what the app and the SDK make of it. A refused operation is a failed Solana transaction: it changes nothing, and costs only the network fee.
Program errors
| Error | Raised when | Usual cause |
|---|---|---|
BadField | A field element in the arguments is at or above the BN254 modulus. | Bytes were not produced by the SDK's frToBytes. |
UnknownRoot | The proof targets a root outside the last 64. | More than 64 notes were added between building the proof and landing it. Rebuild against the current tree. |
InvalidProof | Groth16 verification failed. | See below. |
ZeroAmount | A shield or unshield of 0. | — |
NotYet | A reclaim before the note's not_before. | The validator clock has not reached the return time. |
RateMoved | A shield_stake whose note claims more tokens than the stake pool minted. | The stake pool's rate changed between building and landing — it does once an epoch. Build again. |
TreeFull | The tree holds 2²⁶ notes. | — |
System program errors
| Error | Meaning |
|---|---|
account already in use | The nullifier record exists: the note is already spent. This is the double-spend check. |
insufficient lamports | The payer cannot cover the shield amount, the rent for a nullifier record, or the fee. |
| insufficient funds for rent | An unshield to a brand-new address of less than the rent-exempt minimum (~0.00089 SOL). |
InvalidProof
The proof binds more than the hidden values. It fails when anything it names differs from what the transaction carries:
| Operation | Also bound into the proof |
|---|---|
transfer | both nullifiers, both commitments, the fee |
unshield | the recipient account and the amount |
claim, redeem | the owner account that signed |
reclaim | not_before |
attest | threshold, scope, tag |
So swapping the unshield recipient, signing a claim with a different address,
lowering a not_before or raising a threshold after the fact all surface as
InvalidProof.
The other cause is mismatched keys: a proof built with proving keys from one
setup, checked by a program compiled with verifying keys from another. Every
proof fails, for every user. Rebuild the app's artifacts from the same
circuits/build as the program.
What the app shows
| The app says | Underlying cause |
|---|---|
| That note has already been spent. | account already in use |
| The pool moved on while the proof was being built. Try again. | UnknownRoot |
| The staking rate moved while this was in flight. Try again. | RateMoved |
| Cancelled in the wallet. | The wallet rejected the signature request. |
| The cached tree does not match the chain. | The local mirror diverged — a reset cluster, or another deployment at the same id. The app resyncs by itself. |
| The pool account does not exist on this cluster. | The program is deployed but not initialized, or the cluster is wrong. |
User-facing fixes: Troubleshooting.
Handling them in a wallet
- Mark inputs spent when you submit, not when the transaction lands, so a double click cannot race itself.
- On
UnknownRoot, resync and rebuild. Nothing was spent. - On
already in use, resync and drop the note. The earlier transaction won. - Check an unshield's destination balance before proving, and refuse amounts below the rent-exempt minimum for new accounts.