Skip to main content

Install

The SDK is built on @solana/kit. @solana/web3.js is still needed here because the wallet-adapter bridge hands the wallet a VersionedTransaction to sign — see Solana Kit integration. Add wallet UI packages as needed:

The two wallet callbacks, and what each one is for

A browser integration needs both, and they do different jobs: signMessage also covers viewing-key registration, but that is the smaller half of its job. Without it, a deposit still succeeds and every send afterwards is refused. Full contract, including the approval window and prompt counts: Request authentication.

UTXO integration with wallet signing

For UTXO transact deposits in browser apps, pass wallet signing fields in TransactOptions:
Check signMessage is present before you offer a send or withdraw action. Not every adapter implements it, and finding out after a Groth16 proof has already been computed is a poor experience.

Full wallet-adapter SOL send flow (transact -> fullWithdraw)

Notes:
  • relayUrl comes from the exported CLOAK_PRODUCTION_RELAY_URL, not from an environment read. The SDK reads no environment variable for the submission endpoint, so exporting CLOAK_RELAY_URL does nothing on its own; if you want the value configurable, read your own build-time variable in your app and pass the result as relayUrl. Whatever you pass still has to be an origin on this build’s allowlist. On the default path, where enforceViewingKeyRegistration stays on, omitting relayUrl or passing "" throws Viewing key registration is mandatory: relayUrl is required. before anything reaches the network, deposits included. See which endpoint the SDK talks to.
  • Keep amount values as bigint end-to-end.
  • Use cachedMerkleTree from deposit for the immediate withdraw call.
  • Treat isRootNotFoundError as retryable with bounded backoff.
  • This example spends deposited.outputUtxos in the same breath as producing them, which is what makes it short. A real wallet has to persist them instead: see where your notes live between sessions.
  • The withdrawal here spends two notes at most, so no consolidation round runs and the retry loop is safe to write this way. Handing fullWithdraw three or more notes is a different call with a different failure mode: see a withdrawal of three or more notes is not exempt.

Where your notes live between sessions

Every example above ends by feeding deposited.outputUtxos straight into the next call. A real wallet cannot do that. The user closes the tab, comes back tomorrow, and something has to know what they own. The SDK does not persist anything for you. transact, transfer, partialWithdraw, fullWithdraw and the swap helpers each return their new notes on result.outputUtxos and then forget them. That storage boundary is explicit, and closing it is your job. fullWithdraw leaves nothing shielded, so it hands back no change note when it spends one or two notes. That is the whole of its exemption, and the next section is why. Every other call in that list produces a change note on every path. swapUtxo and swapWithChange additionally return refund and swapStatePda on UtxoSwapResult, which is what reclaims the principal if the swap times out before it settles; persist those two alongside the notes.
Write result.outputUtxos to durable storage before you report success to the user. A note is spendable only with its full secrets, (amount, owner pubkey, blinding, mint), and the blinding of a change note is fresh randomness generated inside createUtxo. It is never published anywhere that can be replayed. If your only copy is lost, that value is unspendable by anyone, permanently. No rescan, no seed phrase and no support path brings it back.

A withdrawal of three or more notes is not exempt

The circuit takes two inputs. partialWithdraw, and therefore fullWithdraw, which is a thin wrapper that calls partialWithdraw for the whole input sum, absorbs a larger set by merging on chain first: while more than two notes remain it spends the two smallest in a shield-to-shield transact and creates one merged note in their place, repeating until two are left, and only then builds the withdrawal itself. For n input notes that is n - 2 settled merges ahead of the withdrawal. Each round is a real transaction. It nullifies two of your notes on chain and publishes the merged commitment, whose blinding is fresh randomFieldElement() randomness living in a local variable inside a call that has not returned yet. You are never handed it, because the merge is an implementation detail of the withdrawal.
If a withdrawal of three or more notes throws part way through, the merged value is gone. The consumed inputs are spent, the merged note sits on chain, and its blinding died with the call that threw. There is nothing to persist, because the call produced no result at all. Exhausted stale-root retries, a rejected wallet prompt, a failed submission or a closed tab all reach that state. No rescan recovers it: a merged note is built with createUtxo, which puts it in the first row of what happens if that storage is lost.
You avoid that window rather than recover from it. Keep withdrawal inputs at two notes or fewer, and when they are not, merge deliberately: build the merged output yourself so you hold its blinding before anything is submitted.
onProgress is the only signal a caller gets that consolidation is happening at all. It fires Consolidating notes (n remaining)... once per round. A withdrawal that emits that string is inside the window above until it resolves, so treat it as a state worth surfacing rather than a spinner label.

The two storage exports, and how they relate

UtxoWallet does not accept a StorageAdapter, and a StorageAdapter does not store Utxo objects. They cover different halves of the problem: UtxoWallet is the spendable note set, the adapters are a ready-made place to keep key material. The note set is the half that decides solvency. For a single note rather than a whole set, serializeUtxo(utxo) returns a Uint8Array and deserializeUtxo(bytes) returns a Promise<Utxo>. Those are the primitives if you keep notes in your own table or send one across a boundary; UtxoWallet.serialize() is the batch form.

A note set that survives a reload

serialize() writes the bigints out as strings, so a plain JSON.stringify over raw Utxo objects is not a substitute; it throws on bigint. Use serialize() / deserialize() as the pair. Then record the result of every flow, in the same tick that the flow returns:
serialize() carries amount, keypair, blinding, leaf index and the spent flag. It does not carry commitment, so notes coming back out of deserialize() have commitment undefined, and verifyUtxos reports every one of them as skipped rather than spent or unspent. Recompute before you reconcile against chain state:

How selectUtxos consumes what storage returns

selectUtxos(available, targetAmount) is a pure function over an array you supply. available is exactly what your store hands back: the unspent notes for one mint.
Two things it does not do:
  • It returns null when the balance is short, rather than throwing. A truthy check is the whole error path.
  • It does not know about the circuit’s two-input limit. It will happily return three notes, and transact then throws Maximum 2 input UTXOs allowed before proving. transfer does not consolidate, so cap the selection yourself. partialWithdraw and fullWithdraw do consolidate, merging the two smallest notes repeatedly until two remain, and that convenience is the one described in a withdrawal of three or more notes is not exempt: it settles merges whose secrets you never receive, so cap the selection before a withdrawal too and run the merges yourself.
UtxoWallet.selectInputs(amount, mint, strategy) does the same job against the wallet’s own bookkeeping and throws instead of returning null.
Do not build sends with UtxoWallet.prepareSend or SimpleWallet. prepareSend creates the recipient output from a freshly generated random keypair rather than from the recipient’s key, so the recipient cannot spend what it produces, and SimpleWallet.sync() throws Sync not yet implemented. Use transfer, which derives the recipient output from the Cloak pubkey you pass it. UtxoWallet is sound for the parts used above: addUtxo, markSpent, getBalance, getUnspentUtxos, getStats, selectInputs, serialize and deserialize.

What happens if that storage is lost

Most of it does not come back. Be precise with yourself about which parts do: scanTransactions is not note recovery. It decrypts your own chain notes with nk and produces the history rows the site documents elsewhere: gross, fee, netAmount and a running balance, ready for toComplianceReport. Two of its fields are the deliberate exception and are kept out of transactions and summary precisely because they are secrets rather than history: deliveredNotes (inbound notes whose sender opted in) and recoveredDepositNotes (your own deposits, but only the ones built recoverably). Everything else it returns tells you a deposit of 0.02 SOL happened. It does not give you something you can spend. The reason sits in the chain note. Every transaction posts one, sealed under the writer’s own viewing key, and its plaintext is { timestamp, noteSalt, outAmount0, outPubkey0, isSendToSelfKey0 }: the output note’s amount and owner pubkey, and no blinding. A chain note alone therefore cannot rebuild a spendable note, which is the whole reason the recoverable-deposit shape exists. See the on-chain chain note is not a delivery channel. scanRecipientDeliveryNotes recovers only notes sent to you by a sender who passed recipientViewingPublicKey. It never surfaces your own change, and it surfaces nothing at all when the sender omitted that option. An empty scan is not proof that nothing is stranded. It means nothing in the scanned window was derivable from this nk.

Making deposits recoverable

If you want a deposit that a (rpc, programId, nk) scan can rebuild later, build the output note with createRecoverableDepositUtxo and hand the same salt to transact:
The salt has to reach the chain note, which is what chainNoteSalt does, and transact refuses a chainNoteSalt that comes without an explicit chainNoteViewingKeyNk. Recovered notes then appear on scanTransactions(...).recoveredDepositNotes, in spendable form, authenticated by recomputing a commitment the deposit actually published. Pick one nk per wallet and use the same value in both places, at deposit time as chainNoteViewingKeyNk and at scan time as viewingKeyNk. getNkFromUtxoPrivateKey(privateKey) derives it from a UTXO private key, and expandSpendKey(skSpend).nsk derives it from a spend key. deriveUtxoKeypairFromSpendKey(skSpend) makes the UTXO keypair itself reproducible, so the keys need never be the thing you lose. The note set still has to be written down.
Two devices sharing one note set will each believe a spent note is unspent, and the second spend fails with DoubleSpend (0x1020). verifyUtxos(utxos, connection, programId) partitions your local notes into spent, unspent and skipped in a single batched RPC call; run it on load and after any failure, and drop anything it reports as spent.

UX requirements

  • Persist result.outputUtxos before you report a flow as successful. A lost change note is unspendable value, not a resyncable cache.
  • Cap withdrawal inputs at two notes, and merge deliberately above that. A withdrawal handed three or more notes consolidates on chain first, and a failure part way through loses the merged value outright, with no result to persist.
  • Show progress during proof generation (onProgress, onProofProgress).
  • Handle stale-root retries gracefully (isRootNotFoundError).
  • Keep circuit files accessible in browser deployments.
  • Ensure viewing-key registration succeeds before transact/swap actions.
  • For privacy history pages, expose explicit cache clear + rescan controls after swaps or failed scans.