Skip to main content
The UTXO API is the current production path for SDK-based app integrations.

Core APIs

  • transact(params, options)
  • transfer(...)
  • partialWithdraw(...)
  • fullWithdraw(...)
  • swapUtxo(...)
  • swapWithChange(...)
All are exported from @cloak.dev/sdk.

Transaction semantics

externalAmount rules:
  • > 0: public deposit into the pool
  • < 0: public withdrawal from the pool
  • 0: fully shielded transfer
Under the hood, SDK builds proof + 264-byte public inputs and submits the resulting on-chain transaction.

Fees

  • SOL withdraw/swap:
    • gross = abs(externalAmount)
    • fee = 5_000_000 + floor(gross * 3 / 1000)
    • net = gross - fee
  • SPL (USDC/USDT) withdraw: fee = 450_000 + floor(gross * 3 / 1000) in token base units, i.e. 0.45 token fixed plus 0.3%, charged in the withdrawn token
  • Deposits and shielded transfers have no protocol fee, but every deposit must meet the on-chain minimum: 10_000_000 lamports (0.01 SOL) for the SOL pool, and 1_000_000 base units (1.00 token) for the 6-decimal USDC/USDT pools. Anything below the minimum is rejected by the program with DepositTooSmall (0x1038). The SDK exports MIN_DEPOSIT_LAMPORTS for the SOL pool only, so an under-minimum USDC or USDT deposit is not caught client side and fails at submission.
SDK helpers you can call directly:
  • SOL: calculateFeeBigint(gross) for the fee, gross - calculateFeeBigint(gross) for the net, isWithdrawAmountSufficient(gross) to check the withdrawal clears the fee.

Create UTXOs

Every result carries notes you must persist

transact, transfer, partialWithdraw, swapUtxo and swapWithChange all resolve to a result whose outputUtxos hold your money: the note a deposit created, and the change note left over by a spend. fullWithdraw is exempt only when you hand it one or two notes, because only then does it leave nothing shielded. Given three or more notes it merges them on chain first, and that path strands money on failure: withdrawing three or more notes consolidates first.
A note is spendable only with its full secrets, and createUtxo draws each note’s blinding from randomFieldElement(). That value is written nowhere on chain; the on-chain commitment is a hash that does not carry it. If you drop a change note, it is unspendable by anyone, permanently. No rescan, no seed phrase and no support path brings it back. Persist result.outputUtxos before you report success to the user.
serializeUtxo and deserializeUtxo are the persistence primitives. Every sample on this page calls the same helper rather than repeating it:
Both filters matter. transact pads its outputs to a fixed count with zero-amount notes, and transfer builds the recipient’s output from the recipient’s public key alone, so that note comes back with privateKey set to 0n and is not yours to hold. Where those bytes should live, how to reload them, and why UtxoWallet.serialize() drops commitment so that verifyUtxos then reports those notes as skipped: where your notes live between sessions.

Withdrawing three or more notes consolidates first

fullWithdraw is a thin wrapper: it sums your inputs and calls partialWithdraw for the whole amount. partialWithdraw can spend only two notes at a time, because the circuit takes two inputs and two outputs. So when either call receives three or more notes, it first merges the two smallest on chain, repeatedly, until two are left, and only then withdraws. Each merge round is a real shield-to-shield transaction. It nullifies the two notes it consumed and creates one merged note whose blinding comes from randomFieldElement() inside createUtxo, the same fresh randomness as any other note. That merged note exists only in a local variable inside the call until the next round consumes it.
If a later merge round or the final withdrawal throws (stale-root retries exhausted, a 5xx on submission, a closed tab, a rejected wallet approval), the merges that already landed are not undone. Their inputs are spent, the merged commitment is on chain, and the call rejected without returning a result, so there is nothing for you to persist. The secrets for that merged note existed only inside the call that just unwound. This is not specific to fullWithdraw: partialWithdraw runs the same loop.
With one or two input notes there is no loop and no exposure. fullWithdraw then genuinely leaves nothing shielded, and its outputs are padding rather than money. onProgress is the only signal a caller gets that consolidation is happening at all. It fires once per merge round with Consolidating notes (n remaining)..., so a call that emits it has already committed to multi-round on-chain work:
The practical defence is to keep your note count low enough that withdrawals never take this path, and to consolidate deliberately rather than letting a withdrawal do it implicitly. A deliberate merge is an ordinary transact with externalAmount: 0n, and it hands the merged note back to you so you can write it down before the next one:
Repeat until two notes remain, then withdraw. Every intermediate note is one you hold on disk, so a failure costs you a retry instead of a note.

Choosing a signer

Every example below comes in two tabs. Which one you need is decided by where your code runs, not by which call you are making.
  • Wallet adapter (browser): you hold no secret key, so you pass the end user’s walletPublicKey together with the adapter’s signMessage. That pair does two jobs: it signs the viewing-key registration message, which is enforced by default, and it produces the authenticated sender that every request submitted on your behalf carries. A deposit additionally needs signTransaction, because a deposit is signed by the funding wallet and posted straight to the chain.
  • Keypair bytes (Node, server, CLI): depositorKeypair covers all of it and never prompts.
Deposits are the reason this bites late. A deposit works with signTransaction alone and carries no authenticated sender, so the deposit below can succeed while the very next call, the private send, is the first one that needs signMessage and walletPublicKey. Leave either of them out on a transfer, a withdrawal or a swap and the call throws before it generates a proof.
Request authentication is the full contract for that signed sender: the exact options, the 300-second approval window, how many wallet prompts each flow costs, and explainRelayAuthRejection for turning a rejection into something a user can act on.

Deposit example (transact)

relayUrl is the exported CLOAK_PRODUCTION_RELAY_URL. The SDK reads no environment variable for it, and the published build accepts only an origin on its allowlist, so any other value throws locally before a request leaves the process. CLOAK_RELAY_URL is a convention the SDK’s own examples and scripts read themselves and pass in as relayUrl. See which endpoint the SDK talks to.

Withdraw and transfer helpers

These three are submitted on your behalf, so each request carries an authenticated sender. In the browser that is what walletPublicKey and signMessage are for, and there is no signTransaction in sight because you are not signing a transaction here. The key you pass becomes the request’s sender, so it must be the end user’s own wallet, never a session or service key.
partialWithdraw/fullWithdraw use negative externalAmount semantics. Each of these asks the wallet to sign exactly once, whatever happens on the network: a stale-root retry re-uses the same proof and the same signed request. See how many wallet prompts to expect.

Swap example

swapWithChange sends a TransactSwap payload (proof + 264-byte public inputs + swap params). A swap returns two things worth keeping, not one. outputUtxos carries the change note, exactly as every other flow does. refund is separate: it is the secret that reclaims the swap principal if the swap times out before it executes, and swapStatePda is the swap it belongs to. Persisting refund is always the fast path. When you passed chainNoteViewingKeyNk, the pair discoverSwapRefunds / matchSwapRefundLeaf can rebuild the same authorization from your nk and the swap’s first input nullifier, so a lost copy is recoverable. When no nk was supplied, the returned object is the only copy that will ever exist, and persisting it is the only path. Swaps are the one flow that can prompt more than once: a swap re-proves on every stale-root retry, and each new proof is a new request needing a fresh approval. maxWalletApprovals caps that at 5 by default, wallet-adapter path only. A depositorKeypair never prompts. Details in request authentication.

Viewing-key registration requirements

  • Default behavior enforces viewing-key registration before protocol txs.
  • TransactOptions.enforceViewingKeyRegistration defaults to true.
  • Registration signs the fixed sign-in message and submits the registration record.
If viewing key is missing, history and decrypt flows cannot resolve your transaction data.

Risk-oracle deposits

When deposits require Range/Switchboard validation, include:
  • riskOracleQueue
  • riskQuoteUrl (or getRiskQuoteInstruction)
The SDK can fetch risk quotes for you; you can also provide your own quote backend or a direct instruction callback. If deposit account list is large, use v0 transactions with addressLookupTableAccounts.

Operational notes

  • Every call hands its new notes back on result.outputUtxos and persists none of them. Write them out with serializeUtxo before you report success. fullWithdraw is exempt only for one or two input notes, where it leaves nothing shielded and its outputs are padding rather than money. See every result carries notes you must persist.
  • partialWithdraw and fullWithdraw merge on chain before withdrawing whenever they are given three or more input notes, and a failure mid-merge strands a note that no result ever hands back. onProgress is the only signal. See withdrawing three or more notes consolidates first.
  • Amounts use bigint in the UTXO API.
  • Proof retries are built in for stale roots (0x1001).
  • SDK-side Merkle reconstruction is preferred for older indices.
  • Optional cachedMerkleTree helps sequential txs avoid extra rebuild/fetch.
  • useUniqueNullifiers defaults to true: every padding UTXO gets a random salt, so padding nullifiers never collide between runs or between concurrent depositors. Leave it unset. Setting it to false restores deterministic padding salts, which collide and fail with DoubleSpend (0x1020) once more than one deposit shares them.

Troubleshooting

  • Invalid public inputs size: the protocol expects 264 bytes, not 232.
  • RootNotFound / 0x1001: regenerate proof with fresh root (SDK retry usually handles this).
  • 0x1020 (double spend): the input UTXO was already spent, usually from another session or device. transact and swapUtxo pre-flight the input nullifiers and throw UtxoAlreadySpentError before any proof is generated. Catch it and call verifyUtxos() to reconcile your local notes with chain state.
  • viewing key not found: re-run a transaction with registration enabled, then rescan history.
  • Unauthorized, a bare 401, or requires an authenticated sender on a transfer, withdrawal or swap: the call had no usable signer, or only half of one. In the browser pass signMessage and walletPublicKey; on a server pass depositorKeypair. Full diagnosis in Request authentication.

Next