Overview
The STRK20 privacy pool is a single contract that holds balances for every token. Your balance in it is not a number on your account but a set of private notes, and you spend them by proving you own them rather than by signing a call. Three things make it different from every other Starkzap module:- A remote proving service builds a validity proof for each private transaction. The proof travels as transaction-level fields, not inside a call, so a privacy operation can never be batched with other calls and cannot go through the transaction builder.
- A relayer submits it. No user signature is involved. The pool authorises the transaction from the proof alone. Your address, nonce, and gas payment stay off-chain, unless you wrap public calls with
invoke. - A viewing key, derived from your signing key, lets you find your own notes and lets others send to you.
send() executes for you and returns a transaction hash. It does not return calls.
For how this compares to Tongo, see Choosing a privacy protocol.
Prerequisites and installation
The privacy SDK is an optional peer dependency published to GitHub Packages, not npm. Point the@starkware-libs scope at that registry in your project’s .npmrc:
read:packages in your ~/.npmrc — never in the committed project file:
The OHTTP transport needs WebCrypto. On Node that means Node 24 or later. The installer does not enforce this, so an older Node fails at runtime. On React Native, Hermes provides no
crypto.subtle at all, so OHTTP needs a library that supplies one; see React Native.Supported wallets
Privacy needs a private-key login.connectPrivacy takes a Wallet rather than a WalletInterface, because a Cartridge wallet cannot derive a viewing key at all.
The viewing key is derived from the account key inside the signer, so the signer has to implement deriveViewingKey. StarkSigner does. The Privy and Cartridge signers can only sign, so they cannot produce the key.
Starkzap refuses those signers before any network call. The user-facing reason, as the example app phrases it:
The privacy pool needs a private-key login: the viewing key is derived from the account key inside the signer, which the Privy and Cartridge signers cannot do.A custom signer opts in by implementing
deriveViewingKey. deriveAccountLeafViewingKey implements the profile for you. It comes from the root entry, not starkzap/privacy, so a signer needs no privacy types:
viewingKeyDerivation.
Configuration
Privacy lives at its own entry point,starkzap/privacy. Its config is passed to connectPrivacy, not to StarkZap. poolContractAddress, prover, discovery and paymaster are required. connectPrivacy throws without a paymaster.
Import from
starkzap/privacy, not from the root. The privacy types name the privacy SDK, which is an optional peer. Exporting them from the root would make that peer mandatory for everyone.connectPrivacy caches one client per wallet, so repeated calls are cheap and the user is asked to sign once. A failure is not cached, so you can retry after fixing the cause. Later calls return the cached client and ignore the config you pass them.
The
paymaster block:
There are no built-in per-network presets for the pool or the services. Supply them per chain yourself.
Running your own services
Both services are open source, soprover and discovery can point at infrastructure you operate.
Deposits are the exception. The prover does not screen deposits itself. It calls a
proof-interceptor sidecar, which sends each deposit address to an elliptic-proxy that holds Elliptic credentials. The client does not see the screening. An allowed transaction returns a proof as usual. A blocked one returns JSON-RPC error 10000. Running that path needs Elliptic access, which StarkWare provides when it onboards you.
The prover backend lives under a different organisation and describes itself as developer tooling, so treat it as a reference implementation rather than a supported production deployment. Check that its API matches the
prover contract your SDK version expects before relying on it.Keep OHTTP on
Passtrue for defaults, or an object to route through a relay that also hides your IP from the discovery service:
Fee modes
Starkzap refuses to pick a fee mode for you. All three modes go through the relayer and withdraw the fee from your private balance, so the choice is about cost, not privacy.
Sponsored modes need an API key, and an API key needs a proxy to hold it. Raise that proxy’s request body limit: a proof runs to a few hundred kilobytes, above the 100 kb that
express.json() allows by default.
Sizing maxFee. The ceiling bounds the whole withdrawal. Under sponsored and sponsored_private the withdrawal is the flat pool fee, so a ceiling a little above that fee is safe. Under default the withdrawal is the pool fee plus gas at the suggested maximum, so leave room for that at the gas prices you expect. quote() reports both figures. Revisit the value when the deployment or the network changes.
Two ways in: connectPrivacy and createPrivacy
Both derive the same viewing key from the same signer. They differ in what you get back and who cleans up.
Use
createPrivacy only for a flow this wrapper does not model, such as a private swap. Use connectPrivacy for everything else.
The revocation row matters most. A viewing key must not outlive its session. Suppose account A disconnects and account B logs in, while a client built from A is still reachable. That client can still read A’s notes. Worse, deposits made by B can be proven as A’s notes, which only A’s key can spend.
Registering and your viewing key
By default the signer derives your viewing key from your account’s private key. It follows theaccount-leaf-v1 profile of SNIP-44: an HMAC-SHA256 keyed with the account key, over a fixed domain separator and a context of chain, account, pool and key slot. Two pools, two chains or two accounts never share a key.
The key is never stored. It can be derived again from the account key, so your notes survive a wallet reinstall with nothing to back up. It lives in the client for as long as the session.
wallet.disconnect() revokes the key. The client refuses to decrypt afterwards, so a stale client someone still holds cannot keep reading notes. revokePrivacy(transfers) does the same for a client built with createPrivacy. See the comparison above. Neither call scrubs memory, and neither undoes the decryption of notes already discovered.
Supplying your own derivation
The pool only sees the key’s public x-coordinate, so you can derive the key any way you like. PassviewingKeyDerivation to connectPrivacy to replace the default.
Usual reasons: a wallet with its own key-derivation function, a hardware device, a key held outside the app, or a signer that cannot run the default derivation.
- Deterministic, forever, on every device. The same context and signer must always produce the same key. The pool keeps the first key it is given. A derivation that changes its output orphans every existing note.
- Inside the pool’s canonical range,
[1, n/2).assertCanonicalViewingKeyrejects anything outside it. Fold your output into the range.
Only the scheme that produced a key can reproduce it. Notes shielded with your scheme need your scheme to recover. Keep the scheme with the account.SNIP-44 is still a draft, so its profile may change before it is final.
deriveAccountLeafViewingKey implements it and is exported, which is what you want when writing a signer’s deriveViewingKey rather than replacing the scheme wholesale.discoverRequirement returns 0 (SetupRequirement.Register) when the account has not registered yet:
register() fails with Insufficient balance. Set autoRegister: true on the first deposit instead.
Whether a recipient can receive is a different question, and needs a different call. A transfer to an account with no registered viewing key cannot be built. Check before offering the action, by asking for the recipient’s channel:
autoSetup does.
Deposit
A deposit needs a public ERC20approve, because the pool pulls the funds. That step names your account either way. You choose whether it is its own transaction or part of the paymaster’s.
In one transaction
send({ invoke }) hands the approve to the paymaster, which relays it through your account’s execute_from_outside in the same transaction as the pool action. The wrapped call runs first, so the allowance is in place by the time the pool reads it.
156 and the reason invalid version. Starkzap replaces that with a message that names the account and points at the two-transaction form.
In two transactions
Send the approve yourself when the account cannot do outside execution, or when you would rather keep the steps apart.The proving block and registration, either way
The approve does not have to age. It is checked when the deposit executes, not when it is proven, so the deposit can follow a separate approve at once. The balance is different. The proof reads your ERC20 balance at the proving block, and the client cannot know when funds arrived. So passprovingBlockId from waitForFundedBalance, which checks the balance directly. This also covers funds that arrived in a transaction you did not send.
Bundling the approve does not change this. It saves a transaction, not the wait, so provingBlockId is needed either way.
This is also where registration happens, via autoRegister.
Tongo’s
fund() bundles its own approve, so there is no choice to make there.Transfer
autoSelectNotes: "naive" lets the SDK pick which of your notes to spend. surplusTo(wallet.address) sends the change back to you as a private note, so it stays in the pool.
Withdraw
Reading private balances
There is nobalanceOf. One discoverNotes() call covers every token, returning a map keyed by token address as a bigint; a balance is a grouping of it:
Previewing a transaction
simulate() runs what send() would run, without proving it. It takes the same callback and options. It quotes the same fee, appends the same fee withdrawal and resolves the same proving block, then runs against a mock prover.
send()’s onWarnings callback reports the same list, but only after the proof is paid for. simulate() costs only a quote, a chain-head read and a view call.
It waits for no block and writes no private state, so it is safe to run while a send is in flight. In that window it reads a slightly older block than the send will prove against.
The proving block matters. Channels and notes are discovered at that block. A preview against the chain head would describe a different transaction, for example when the recipient registered in the last few blocks.
Fees: pool fee and gas
The pool charges a fee that is withdrawn inside the proof, separately from gas.quote() returns it:
gas is the paymaster’s gas estimate, not your fee. The two are the same only in default mode, where the withdrawal equals suggestedMaxInGasToken. In the sponsored modes the relayer pays the gas, so do not show those numbers to the user as a cost.
In default mode, show the estimate next to the suggested maximum. The difference is headroom the user pays for and may not use. gas is optional. A deployment may omit it, and a figure Starkzap cannot read is dropped, not treated as an error.
An amount of 0n means the deployment charges nothing, and no withdrawal is added. A proof that omits a non-zero fee withdrawal is rejected by the paymaster.
Re-quote after each operation — the amount can change.
Waiting for a provable block
The proving block is 10 blocks behind the chain head (PROOF_BASE_BLOCK_DEPTH). Any on-chain state a proof reads must be in a block before the proving block. This includes your viewing key, your token balance and the spent-note set. So you must wait after a previous private transaction, after deploying the account before register(), and after funding the account before deposit().
Without the wait, the sequencer rejects the proof, or the proof reads a balance the chain does not have yet. Both fail late, with unclear errors.
send() waits for you. Your job is to show the wait. It is silent, and it takes seconds on Sepolia but minutes on mainnet. A plain spinner looks like a hang:
provingBlockId:
Use these when you have no receipt, for example when another party funded the account. They read the chain state instead of counting blocks from a receipt.
All three throw on timeout (default 300 seconds) rather than resolving — handle that.
Best practices
- Leave OHTTP on. Disabling it exposes your viewing key to the service operators.
- Withdraw to a fresh address. Reusing the deposit address links both ends of the pool and undoes the privacy you paid for.
- Show
quote()before the user commits, and never present simulated gas as the total cost. - Show
wait.onAttempt. A silent wait of minutes looks like a hang.waitalso carriesdepth,pollIntervalMsandtimeoutMsfor chains the two-second default does not suit. - Never persist the viewing key. It is reproducible from the signing key; storing it only creates something to leak.
wallet.disconnect()revokes it; callrevokePrivacy()yourself if you built the client withcreatePrivacy. - Raise the body limit on any proxy in front of the paymaster. A proof is a few hundred kilobytes — past the default in most HTTP stacks.
- Keep prover and discovery URLs out of version control. Configure them per environment.
- Check the recipient’s channel before offering a transfer so users get a clear reason instead of a build failure.
discoverChannels, notdiscoverRequirement— see above. - Keep
invokefor calls that are already public. It is there to save a transaction, not to shield one: the calls are executed by the account and name it on-chain.
Troubleshooting
Missing optional peer dependency
The error names the registry and the required permission, and mentionsread:packages and Node >= 24. Confirm your project .npmrc maps the @starkware-libs scope to https://npm.pkg.github.com, that ~/.npmrc holds a token with read:packages, and that you are on Node 24 or later.
Signer refused before any network call
The signer does not implementderiveViewingKey. Use a private-key login backed by StarkSigner; Privy and Cartridge signers cannot derive a viewing key. A custom signer can implement the method — deriveAccountLeafViewingKey is exported for exactly that — or you can pass your own viewingKeyDerivation.
Insufficient balance on register
Registration cannot stand alone. The pool fee comes from a private balance you do not have yet. Deposit with autoRegister: true instead. This also appears when the quoted pool fee exceeds the amount you are depositing.
Screening rejected the deposit
Deposits are screened server-side before a proof is produced. Classify the failure rather than showing a raw error:Paymaster rejected the transaction
Starkzap reports the paymaster’s own message and the reason from itsdata. Read that first. A few codes are worth knowing:
A retry means re-proving. The paymaster remembers the calls it has seen, so re-submitting the same proof answers
156 :: execution error Tx already sent.
non-JSON response or an HTTP status you did not expect
Something in front of the paymaster failed, usually a proxy. Starkzap reports the real HTTP status as the error’s code and keeps the response body in data. So a 413 from a proxy that caps request bodies is distinguishable from a paymaster rejection.
A proof is around 300–320 kb for a simple transfer, and larger for transactions carrying more actions. That is above express.json()’s 100 kb default, so a proxy in front of the paymaster needs its limit raised; the example server uses 4mb.
Prover or discovery URL rejected
Both must parse as URLs and usehttps://, or http:// on a loopback host (localhost, 127.0.0.1, [::1]). Plain http:// to any other host is refused, because both services receive the viewing key. The same rule applies to paymaster.url and the OHTTP relay. For a trusted network such as a LAN devnet reached from a device or emulator, set allowInsecureHttp: true in the privacy config. A warning is still printed for any plain-http service URL.
Web bundler reloads the page on first use
The SDK reaches its optional peers throughawait import(...), which Vite’s scanner does not see. Add @starkware-libs/starknet-privacy-sdk to optimizeDeps.include so it lands in the first pre-bundle pass.
Next steps
- Choosing a privacy protocol — STRK20 compared with Tongo
- Tongo confidential transfers — the other privacy protocol
- Paymasters — how relayed submission works
- Transactions — execution options
- API Reference — full method signatures