Install
@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 UTXOtransact deposits in browser apps, pass wallet signing fields in TransactOptions:
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)
relayUrlcomes from the exportedCLOAK_PRODUCTION_RELAY_URL, not from an environment read. The SDK reads no environment variable for the submission endpoint, so exportingCLOAK_RELAY_URLdoes nothing on its own; if you want the value configurable, read your own build-time variable in your app and pass the result asrelayUrl. Whatever you pass still has to be an origin on this build’s allowlist. On the default path, whereenforceViewingKeyRegistrationstays on, omittingrelayUrlor passing""throwsViewing 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
bigintend-to-end. - Use
cachedMerkleTreefrom deposit for the immediate withdraw call. - Treat
isRootNotFoundErroras retryable with bounded backoff. - This example spends
deposited.outputUtxosin 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
fullWithdrawthree 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 feedingdeposited.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.
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.
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.
- It returns
nullwhen 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
transactthen throwsMaximum 2 input UTXOs allowedbefore proving.transferdoes not consolidate, so cap the selection yourself.partialWithdrawandfullWithdrawdo 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.
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:
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.outputUtxosbefore 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.