transfer(...): the same instruction as a deposit or withdrawal, but with the amount crossing the pool’s edge set to zero (externalAmount === 0).
The one thing that surprises everyone: a shielded transfer is a bearer instrument — whoever holds the note’s secrets can spend it, like cash — not an address you send to. You don’t type in a Solana address and watch it appear in their wallet. Delivery of those secrets is a separate step from the transfer itself, and you choose how it happens: opt in to recipient discovery and the SDK publishes them, sealed to the recipient, on chain; leave it out and you hand them over off-chain yourself. The walkthrough below makes it concrete before we get to the mechanics.
This is not the consumer app’s private send, which delivers liquid SOL to a public Solana address (that’s a withdrawal). A shielded transfer keeps the recipient inside the pool. It’s an SDK-level capability; no first-party web UI exposes it yet.
Who signs. A shielded transfer is not posted by your wallet the way a deposit is: it is submitted for you, so the request carries an authenticated sender, a signature over the request body from the end user’s own wallet. On a server you pass
depositorKeypair. In a browser you pass signMessage and walletPublicKey from your wallet adapter instead, because an adapter has no secret key to hand over. The examples on this page use the server form; Request authentication is the full contract, including the browser wiring, the 300-second approval window and explainRelayAuthRejection.Walkthrough: Alice pays Bob 0.06 SOL, privately
Alice and Bob each have two separate identities — this distinction is the whole ballgame:
The Cloak pubkey (a note’s
owner_pubkey) is derived from a private spending key — a completely different key from your Solana wallet, and just a plain number that lives inside the pool’s cryptographic math. Keep those two apart and everything else follows.
deriveViewingKeyFromNk(bobNk).publicKey), which is what lets Alice address a discovery envelope to him. It is also a public key: sharing it grants no ability to spend.
2 · Alice calls transfer. She spends her 0.10 note and creates two new notes: 0.06 owned by Bob’s Cloak pubkey, and 0.04 change owned by herself. Because externalAmount === 0, the pool’s lamports don’t move at all — only who owns what changes:
Value moved between two private balances — Alice
0.10 → 0.04, Bob 0.00 → 0.06 — while the pool total and every public wallet stayed flat. (Bob’s ~0.0548 is his 0.06 note minus the exit fee, charged only when value leaves the pool at withdraw.)
3 · Bob finds it himself, if Alice opted in. Because Alice passed Bob’s viewing public key as recipientViewingPublicKey, the send published a sealed envelope on chain carrying the note’s amount and blinding, addressed to Bob. Bob calls scanRecipientDeliveryNotes holding nothing but his own keys and it comes back. If Alice had not passed that key, Bob would find nothing: the blinding would never have reached the chain, and there’d be no trail from the note to him. (The full reason.)
4 · Fallback, only when Alice skipped the envelope: Alice hands Bob the note’s secrets — the amount + blinding (a secret random value Alice picked when building the note), effectively the note object — off-chain. Now Bob attaches his own key and the note is spendable by him.
5 · Bob withdraws to any wallet he likes. His 0.06 private balance becomes ~0.0548 public SOL (minus the exit fee). Only this last step moves SOL out of the pool.
That’s the entire mechanic: ownership moves on-chain in step 2, and delivery rides along on chain in the sealed envelope. Skip the envelope and delivery falls back to the off-chain handoff in step 4; a transfer with neither is a note Bob owns but can’t use.
How the recipient gets the secrets
To spend a note you need its full secrets —(amount, owner_pubkey, blinding, mint) — because spending means recomputing the commitment and proving ownership in zero knowledge. The recipient supplies owner_pubkey (it’s their key), but the blinding is fresh randomness the sender rolls in. It is not part of the commitment the chain stores, and it reaches the recipient on chain only through the sealed delivery envelope described below.
So receiving takes one up-front exchange. Before the send, the recipient shares two things: their Cloak pubkey (the note’s owner_pubkey) and their public viewing key, deriveViewingKeyFromNk(nk).publicKey. The sender passes the second as recipientViewingPublicKey in the transfer(...) options. The SDK then seals {amount, blinding} of the recipient’s note into a 112-byte envelope (X25519 + XSalsa20-Poly1305) and it is published on chain. The recipient finds and opens it with their nk alone, via scanRecipientDeliveryNotes({ connection, programId, viewingKeyNk, ownerUtxoPublicKey }), which trial-opens every carrier and keeps the ones whose recomputed Poseidon commitment matches. No second hand-off.
When the sender omits recipientViewingPublicKey, the blinding never reaches the chain and the note secrets have to be handed over out of band instead:
- Before — recipient → sender: the recipient’s Cloak pubkey, so the sender can lock the note to it.
- After — sender → recipient: the note secrets, so the recipient can actually spend it.
- The envelope is attached only on a shield-to-shield send (
externalAmount === 0with no externalrecipient). Deposits and withdrawals carry none. - It always describes output 0, and it is skipped when output 0 belongs to the spender (change, or a send-to-self).
transfer()puts the recipient’s note at output 0, so the documented API satisfies this for you. - The carrier’s declared commitment is not covered by the envelope’s Poly1305 tag. Pass
ownerUtxoPublicKeytoscanRecipientDeliveryNotesand trust thecommitmentVerifiedflag on the returned note rather than the memo.
The on-chain “chain note” is not a delivery channel
Every transfer posts a small encrypted chain note, which is easy to mistake for delivery — it isn’t. It’s an AES-256-GCM envelope sealed under a key derived from the sender’s own viewing key plus the output commitment, so opening it takes both, and the recipient has neither. Its contents are{ timestamp, noteSalt, outAmount0, outPubkey0, isSendToSelfKey0 }, which does include the recipient note’s amount and Cloak pubkey, but not the blinding. Even if it did, the envelope isn’t addressed to the recipient in the first place. It’s a self-scan / compliance record for the party who wrote it, not a channel that delivers a spendable note to anyone else. (See viewing keys & compliance.)
The recipient-addressed delivery envelope (CLKD1) described above is the separate artifact that does deliver. Don’t confuse the two: the chain note is sender-keyed and compliance-facing; the delivery envelope is recipient-keyed and is the actual delivery channel.
Who can spend the note — and the catch
Ownership is cryptographic, so the note is safe in a strong sense and stuck in a subtle one. Only the owner’s key can spend it — not even the sender. Spending requires proving, in zero knowledge, that you hold the private key behind the note’sowner_pubkey. Alice built Bob’s note and knows its amount and blinding, yet she still cannot spend it — she doesn’t have Bob’s private key, and the secrets alone aren’t enough. Owning a note’s data ≠ being able to spend it; the ownership check is a separate cryptographic gate. An attacker who learns everything except the private key gets nowhere.
The catch: no clawback. The flip side of that safety is that a raw shielded transfer has no refund path. If the sender never hands over the secrets, the note is stranded — the recipient can’t spend it (no secrets) and the sender can’t either (no key). This is exactly why payment links use an ephemeral key the sender controls: an unclaimed link stays reclaimable, where a raw send-to-a-Cloak-pubkey does not.
Relationship to payment links
A shielded transfer and a payment link are the same bearer mechanic with different key ownership:- Shielded transfer — the note is locked to the recipient’s own Cloak key. The recipient must exist and share their Cloak pubkey and viewing public key up front: one off-chain exchange, after which the sealed envelope carries the secrets on chain.
- Payment link — the sender generates a throwaway key, funds a note to it, and packages the whole claim into a single link handed off-chain. The recipient needs no Cloak identity and gives nothing up front — whoever holds the link claims it: one off-chain handoff.
Under the hood
If the walkthrough is enough, skip this. If you want the machinery: Value lives as notes. A note is a set of secrets(amount, owner_pubkey, blinding, mint); only its commitment — a Poseidon hash over those fields — goes on-chain, as a leaf in the pool’s Merkle tree. The preimage is secret, so the chain sees an opaque hash, not “5 SOL owned by X.” Fresh blinding per note means equal amounts don’t produce equal commitments.
Ownership is a key, not an account. owner_pubkey = Poseidon([spending_privkey], KEYPAIR_DOMAIN_TAG) — that’s the “Cloak pubkey.” To spend, you prove in zero knowledge that you know the spending key behind the pubkey baked into the commitment.
Nullifiers stop double-spends. Spending a note publishes its nullifier (derived from the note + your spending key). The program records every nullifier and rejects repeats — and a nullifier is unlinkable to its commitment, so nobody can tell which leaf it spent.
One instruction does everything. Deposit, transfer, and withdraw are all the same Transact instruction — a Groth16 proof plus public inputs, 2-in / 2-out (pad an unused input with a zero note; all-zero outputs are dropped before the tree append). The proof establishes that the inputs are in the tree, you own them, the nullifiers are correct, the outputs are well-formed, and conservation holds:
public_amount is the entire taxonomy — it’s the circuit’s name for the same knob the SDK exposes as externalAmount (the amount crossing the pool’s boundary):
So Alice’s 0.06 transfer out of her 0.10 note is:
public_amount = 0. An observer sees two nullifiers, two opaque commitments, and a zero public amount — so the amount doesn’t even leak, and sender and recipient aren’t linkable.
Example: end to end
Bob shares two public values once, up front. After that the SDK delivers: the send seals the note’s secrets to Bob’s viewing key and publishes them on chain, and Bob’s scan picks them up. This version runs server-side, so every call authenticates with a localdepositorKeypair. From a browser, everything else stays identical and that one option is replaced by signMessage + walletPublicKey: see the wallet-adapter version right below, or Request authentication for the whole contract.
It starts one step earlier than the walkthrough did, with the deposit that creates Alice’s note, so aliceNote is a note the script actually funds rather than a name you are left to fill in yourself.
Where
aliceNote comes from in a real app. The script above funds it in the same run, which is what makes it runnable end to end. A wallet cannot do that: the deposit happened yesterday, and the note has to come back out of storage. The SDK persists nothing for you: transact and transfer hand you result.outputUtxos once and then forget them, and a change note’s blinding is fresh randomness that exists in no other place, on chain or off. Write the outputs down before you report success. The deposit above is built with createUtxo, so a later cold scan cannot rebuild it either; createRecoverableDepositUtxo is the shape that can. Both are covered in where your notes live between sessions.Drop
recipientViewingPublicKey and you are back on the bearer path: Alice must read the recipient note out of sent.outputUtxos[0] and hand Bob its amount and blinding off-chain herself, on top of banking her own change from sent.outputUtxos[1]. Without either the envelope or that hand-off, the note is orphaned — Bob owns a commitment he has no secrets for, and no scan will surface it.Browser: the same send from a wallet adapter
A wallet adapter has no secret key to give you, so there is nodepositorKeypair to pass. You hand the SDK the wallet’s signMessage plus the connected walletPublicKey, and the SDK builds and signs the request with them. The send itself is unchanged: same recipient Cloak pubkey, same recipientViewingPublicKey opt-in.
The input notes are the other difference, and it is a difference of plumbing rather than of protocol. There is no deposit a line above to take them from, so they come back out of your own storage: a UtxoWallet you loaded on mount, with selectUtxos picking the inputs for this amount.
UtxoWallet.serialize() drops each note’s commitment. It is spend-complete, so nothing here is
lost, but notes restored with UtxoWallet.deserialize come back from verifyUtxos as skipped
rather than unspent. Read skipped as “not checked”, never as “gone”.- The user sees exactly one prompt. A shielded transfer signs once, and a stale-root retry re-uses the same proof and the same signed request, so no second dialog appears.
- The signed timestamp expires.
REQUEST_AUTH_MAX_AGE_SECONDSis300and the SDK holds a little of that back for serializing and shipping the request, leaving roughly 285 seconds to approve. An approval dialog left sitting on a desk comes back to a dead request: nothing was submitted and nothing is at risk, but the whole send, proof included, has to be started again.
Where next
Where your notes live between sessions
What to persist the moment a send returns, and which losses no scan can undo.
UTXO Transactions
The
transfer / transact API, externalAmount semantics, and fees.Request authentication
Who signs a shielded transfer, the browser wallet-adapter wiring, and the approval window.
Payment links
The productized version that needs nothing from the recipient up front, with a refund path.
Moving value out of your private balance
The consumer view — the two ways value leaves the pool (send / withdraw), and where this fits.
Viewing keys & compliance
What the self-scan really is, and how it powers compliance.