Skip to main content
Deposits go straight to the chain, signed by the wallet that funds them. Every other flow is submitted on your behalf, and each of those requests carries an authenticated sender: a short ed25519 signature over the exact request body, produced by the end user’s own wallet. This page is the whole contract. Read it before your first private send, because the deposit example you probably ran first does not exercise any of it.
If you are in a browser and your call fails with Unauthorized or a bare 401, or throws requires an authenticated sender, you are on the right page. Jump to Wiring it up.

Which flows need it

Deposits are why this bites people late. A deposit is signed by the funding wallet and posted directly, so it works with signTransaction alone and never touches request authentication. The Quickstart deposit succeeds, the integration feels wired up, and then the very next call, the send, is the first one that needs a signed sender. Nothing was misconfigured at deposit time; that step simply does not use this mechanism.

Wiring it up

You supply one of two things in TransactOptions. The SDK builds and signs the request either way.
One rule applies to every sample below. Authentication decides whether a request is accepted; it does nothing about what comes back. transfer, transact, partialWithdraw, swapUtxo and swapWithChange all hand you result.outputUtxos once and then forget them, and one of those notes is your user’s own change. Its blinding is fresh randomness drawn inside createUtxo and written nowhere on chain, so a change note that is not in durable storage before you report success is permanently unspendable by anyone: no rescan, no seed phrase, no support path. Persist with serializeUtxo, read back with deserializeUtxo. fullWithdraw is the one exemption, and only when it spends one or two notes: handed three or more it merges them on chain first, and a failure part way through loses the merged value with no result to persist. Full treatment: where your notes live between sessions and a withdrawal of three or more notes is not exempt.

Browser: useWallet()

This is the complete browser path. It holds no secret key, and it is what a wallet adapter can actually supply.
signMessage must return the 64-byte ed25519 detached signature, which is exactly what @solana/wallet-adapter-react returns. Nothing else about the flow changes between a wallet and a keypair; only the holder of the pen changes.
walletPublicKey and depositorPublicKey are two names for the same end user. Pass whichever you already have. Passing both with different keys throws: the SDK uses that one key for the request’s sender, for screening, and for viewing-key registration, so splitting it across two keys would authenticate one person and screen another.

Server: a local keypair

A keypair signs in microseconds and never prompts.

Neither: the call throws before it proves anything

If a relay-submitted flow has no usable signer, the SDK refuses at the top of the call:
That check is deliberate and it is placed ahead of every await in the flow. The alternative, which is what earlier builds did, was to fetch a Merkle proof, fetch a risk quote, compute a full Groth16 proof, ship it, and hand back an opaque Unauthorized: slow and uninformative. A half-configured signer gets its own message naming the missing half.

The sender must be the end user’s own wallet

Whatever key you pass becomes the request’s sender, and on a shield-to-shield send that is the key screened for sanctions.
Never substitute an ephemeral, session or service-held key here. It is not a shortcut around a wallet prompt, it moves the screening onto somebody who is not the user. If your architecture makes the user’s wallet unavailable at send time, the send does not belong on that code path.
The same key is also the one a viewing key is registered against. A keypair paired with a different walletPublicKey registers for one wallet and authenticates as the other, and the only symptom is a rejection saying the authenticated sender has no registered viewing key, about a key you believe is registered. The SDK catches that pairing up front rather than letting it reach the network.

The approval window

The timestamp is signed, so it cannot be refreshed after the fact. Two clock facts follow, and both are exported so you can build UI around them: The SDK holds 15 of those 300 seconds back for serializing the body and getting the request onto the network, so the real budget a user has to approve a prompt is 285 seconds. Past that the SDK fails the call locally, with a message saying nothing was submitted, rather than shipping a proof that is already dead on arrival. Two consequences worth designing for:
  • An approval dialog left sitting fails. A hardware wallet prompt that waits five minutes on a desk comes back to a request that has expired. Nothing moved and no funds are at risk, but the user has to start the operation again, including the proof. Keep the prompt in front of them.
  • A wrong machine clock fails everything. In a browser the timestamp comes from the user’s own laptop. If it runs fast, every request is refused before anything else is even looked at, and the fix is on their machine, not in your code.
explainRelayAuthRejection(responseText) turns those into something a person can act on. It returns null for anything that is not an authentication rejection, so it is safe to append unconditionally:

How many wallet prompts to expect

A private send, or a withdrawal of one or two notes, signs exactly once, whatever happens on the network. Stale-root retries re-use the same proof and the same signed request, so the user sees one dialog and no more.
A withdrawal of three or more notes is the quiet exception. It merges the two smallest notes on chain until two remain, each merge is its own submitted request, and each request needs its own approval: budget n - 1 dialogs for n input notes. maxWalletApprovals does not cap this, it applies to swaps only. Read a withdrawal of three or more notes is not exempt before you offer that shape, because a prompt declined mid-merge is exactly where value is lost. Swaps are the other exception, because a swap re-proves on every retry and each new proof is a new request that needs a fresh approval. maxWalletApprovals caps how many times one swap may ask:
  • Applies only to the wallet-adapter path and only to swapUtxo / swapWithChange.
  • Default 5. Without the cap, maxRootRetries alone would allow 41 dialogs for a single swap.
  • A depositorKeypair never prompts and stays bounded by maxRootRetries as before.
A swap has a second secret, and it is not a note. Alongside the change note on outputUtxos, swapUtxo and swapWithChange return refund: the key material that reclaims the swap principal if the swap times out before it executes. Persist it beside the change note, keyed by swap.swapStatePda. Persisting it is the fast path always, and the only path when no chainNoteViewingKeyNk was passed to the swap, because that is the case where refund is the only copy that will ever exist. When an nk was passed, refund.derivedFromNk comes back true and discoverSwapRefunds / matchSwapRefundLeaf can rebuild the same secret from the nk plus the swap’s public input nullifier, so losing the object is recoverable rather than terminal.

Driving submission yourself

Most integrations never need this. Reach for it when you are running your own retry or queueing layer around submission. submitTransactToRelay is the supported seam. It takes the same two alternatives, a depositorKeypair or a relayAuthSigner, and handles the signing, the wire fields and the on-chain settlement check for you:

Signing a body by hand

Below that, buildRelayAuthPreimage gives you the bytes to sign without signing them, which is the only shape a wallet adapter can work with:
preimage.message is a plain ed25519 detached-signature preimage, newline separated:
The signed view is your body reduced to exactly the fields in the list you passed, plus sender, with every field you omitted written as an explicit null. canonicalJson is exported so you can reproduce that digest yourself: keys sorted bytewise, no whitespace. It refuses values that the two sides could render differently (non-integer or beyond-safe-integer numbers, functions, symbols) rather than signing a digest that cannot match, so keep u64 amounts as decimal strings. buildRelayAuthPreimage generates the nonce and timestamp once per call. If you re-POST a request, re-send the same preimage and the same body bytes. Rebuilding it produces a brand-new request, not a retry.
slippage_bps must always be present in a signed swap body. It is the one field in TRANSACT_SWAP_AUTH_FIELDS that cannot be represented as null on the other side: omit it and you sign "slippage_bps":null while the receiving side signs its own default of 500. The digests diverge, and the rejection never mentions slippage. Every other field in both lists signs as an explicit null when absent, which is why this one is the trap. The SDK’s own swap path always sets it; hand-built bodies must too, and buildRelayAuthPreimage will refuse the call rather than sign a digest that cannot match.
The 9 transact fields and the 19 swap fields are exported as TRANSACT_AUTH_FIELDS and TRANSACT_SWAP_AUTH_FIELDS. Read them from the package rather than retyping them; the order is part of the agreement.

Which endpoint the SDK talks to, and which RPC you use

These are two separate things and they are easy to conflate. The submission endpoint is pinned when the SDK is built. relayUrl no longer selects it. The published build accepts exactly two values:
  • an origin on this build’s allowlist, best written as the exported CLOAK_PRODUCTION_RELAY_URL rather than typed by hand. Anything else throws, naming the value and the allowlist.
  • "", the caller-signed direct-submission signal, meaning no endpoint at all: you sign and submit yourself. On its own it still throws; see the warning below.
There is no default. Omitting relayUrl yields undefined, never production. riskQuoteUrl is held to the same allowlist, because it is converted back into a base URL, so it must be an absolute URL on the allowlist rather than a relative path.
"" is not a working value by itself. Omitting relayUrl, or passing "", makes every flow including deposits throw Viewing key registration is mandatory: relayUrl is required. before any network call. The direct-submission half only takes effect once enforceViewingKeyRegistration is also set to false, because the viewing-key gate defaults on and fires first. That pair is not something to reach for: with a signer present it submits a transfer or withdrawal under the user’s own key, publicly linking it.
A mistyped relayUrl is not a 401, and the two failures live in different places. A value off the allowlist throws locally, naming the value you passed and the allowlist, before anything is signed and before any request leaves the process. Nothing was submitted and nothing was refused, so authentication is not what went wrong; import CLOAK_PRODUCTION_RELAY_URL instead of typing the string. A 401, an Unauthorized, or a rejection that explainRelayAuthRejection can explain means the opposite: the endpoint was accepted, the request arrived, and the signed sender is what was turned away. Only that second case belongs on this page.
Your RPC is entirely your own choice. The pin covers the submission endpoint only. You build the CloakRpc with createCloakRpc(rpcUrl) and Cloak never inspects, replaces or proxies it. Any public RPC, any private provider, any paid endpoint works. The one exception is a loopback RPC (localhost, 127.0.0.1), which a production build refuses because a production artifact talking to a local validator is always a mistake. “You have to use Cloak’s RPC” is a misreading of this. You do not.

One more origin: the circuits bundle

A proof is generated on the end user’s own device, so the proving artifacts have to reach it. On the first proof of a page’s life the SDK fetches two files from the bundle base this build was compiled against, exported as DEFAULT_TRANSACTION_CIRCUITS_URL:
Just under 23 MB together, and those sizes are exact rather than approximate: both files are hash-pinned in the SDK build and re-checked against the pinned digests before a byte of either reaches the prover, so bytes of any other size do not prove. This one reaches deposits too. Request authentication is the mechanism a deposit skips; proof generation is not. Every row of the table at the top of this page proves, so a policy that blocks the bundle breaks the Quickstart deposit as well, on the very first call you make.
A strict Content-Security-Policy has to name this origin. The artifacts arrive over an ordinary fetch, so connect-src is the directive that governs them, and the witness generator is then compiled as WebAssembly inside the page, which a strict script-src has to permit separately:
Miss the connect-src entry and the browser refuses the request before it leaves the page. The failure then surfaces from inside proof generation as a failed fetch naming the artifact URL, which points at the SDK rather than at the policy that actually blocked it. The line that names your policy is in the browser console, not in the error you catch.
Caching is in memory and per page load. Verified artifact bytes are memoised for the life of the process and keyed by base, so a user who sends three times downloads once, not three times. The SDK keeps no persistent cache of its own: a reload starts from an empty memo, and whether anything is actually re-downloaded is left to the browser’s ordinary HTTP cache. If you would rather pay that cost on mount than at the first click, warm it yourself; on success the check memoises the same bytes the prove path later reads.
This is one asset origin, not a claim on your infrastructure. The request carries no body, no headers of yours and none of your user’s data; it is a static download of the public ceremony bundle, and the digest pin is why fetching it from a Cloak-controlled host is safe rather than trusting. It differs from the RPC in one way worth knowing before you plan around it: like the submission endpoint, the base is fixed when the SDK is built, so re-serving the artifacts from your own CDN is not a call option. setCircuitsPath() does accept a local directory holding the same two files, which covers an offline or air-gapped Node process, but a browser has no filesystem to read and refuses that shape.

Pointing at a non-production endpoint

You cannot. The allowed origins are decided when the SDK is built, so a published build reaches exactly one submission endpoint and nothing a consumer supplies changes that — not a call option, not an environment variable, not a bundler define. CLOAK_PRODUCTION_RELAY_URL is the value to pass; anything else throws before a request is made, naming what you passed and what this build is pinned to. The loopback-RPC guard (BUILD_ALLOWS_LOCAL_ENDPOINTS) is derived from the same pin, so a production build also refuses a localhost RPC. If you need a non-production target for an integration rehearsal, talk to us rather than working around the pin.
Why this is not an environment variable. An env var, a NODE_ENV check or a bundler define all resolve inside the consumer’s process at the consumer’s build or run time. That is precisely the moment the pin exists to constrain, so a value read there pins nothing. The only value a published artifact carries that a consumer cannot supply is one decided when the artifact was built. It is an integrity and product-control mechanism, not a security boundary: anyone who can run code in the consumer’s process can patch it. What it buys is that the correct endpoint is the only one reachable by accident.

Wallet integration

Full wallet-adapter wiring, progress callbacks and stale-root retries.

Error handling

CloakError categories, parseError, and the stale-root retry template.

Shielded transfers

What a private send actually does, and how the recipient gets the note.

API reference

The full exported surface, including every symbol on this page.