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
Wiring it up
You supply one of two things inTransactOptions. The SDK builds and signs the request either way.
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
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: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’ssender, and on a shield-to-shield send that is the key
screened for sanctions.
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.
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,maxRootRetriesalone would allow 41 dialogs for a single swap. - A
depositorKeypairnever prompts and stays bounded bymaxRootRetriesas before.
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:
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.
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_URLrather 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.
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.
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 asDEFAULT_TRANSACTION_CIRCUITS_URL:
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.Related pages
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.