Skip to main content

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.
Deposits require access from StarkWare. Every deposit is screened against sanctions lists through Elliptic. That screening runs on credentials StarkWare administers, so the proving service behind it is not an open endpoint. Contact StarkWare to deposit.Everything else you can run yourself. Transfers, withdrawals and the discovery service can be self-hosted from the open-source components. See Running your own services. The SDK is a prerelease (0.14.x-rc) published to GitHub Packages and requires Node 24 or later.
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.
The client handles the fee, the proving block and the submission. So 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:
Reads require authentication even though the package is public. Put a token with read:packages in your ~/.npmrc — never in the committed project file:
Then install:
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:
Failing that, supply your own 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, so prover 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

With ohttp disabled, your viewing key reaches the proving and discovery services in plaintext — inside TLS, but readable by whoever operates them. Turn it off only if the server does not support OHTTP.
Pass true 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 the account-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.
The pool stores the first key an account registers and it cannot be replaced. Changing viewingKeyDerivation for an account that already registered orphans its notes, and the discovery service rejects a mismatched key outright.

Supplying your own derivation

The pool only sees the key’s public x-coordinate, so you can derive the key any way you like. Pass viewingKeyDerivation 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.
Two requirements, both enforced:
  • 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). assertCanonicalViewingKey rejects 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.
Registration is per account, not per token, so any token answers the question. discoverRequirement returns 0 (SetupRequirement.Register) when the account has not registered yet:
You cannot register on its own. The pool fee comes from your private balance, which is empty before your first deposit. A standalone 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:
Do not use discoverRequirement for the recipient. With the indexer discovery service, its Register verdict reports whether the sender is registered. Once you have deposited, it answers “ready” for any address. The transfer then fails with Missing channel context for recipient. The channel’s publicKey is what the build needs, so check for that.
A recipient who is registered but has no channel with you yet passes, as it should: opening that channel is what autoSetup does.

Deposit

A deposit needs a public ERC20 approve, 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.
You sign the paymaster’s SNIP-12 typed data rather than a transaction, and Starkzap asks for that signature before proving. The signature does not depend on the proof, so declining costs you nothing.
Wrapped calls are not private. They are executed by your account and name it on-chain. Use this for work that is already public, which the approve is — not to hide anything.
This needs an account that supports SNIP-9 outside execution. The OpenZeppelin, Argent and Braavos presets all do. When an account does not, the paymaster refuses at build time with code 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 pass provingBlockId 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

Make the recipient an explicit choice in your UI rather than defaulting to the connected wallet.
Deposits and withdrawals are public. Only what happens between them is private. Withdrawing to the address you deposited from links both ends. Use a fresh address.
Withdrawing to yourself is legitimate. It just has to be a choice, not a default.

Reading private balances

There is no balanceOf. One discoverNotes() call covers every token, returning a map keyed by token address as a bigint; a balance is a grouping of it:
Note counts are worth surfacing: a balance split across many small notes behaves differently from the same amount in one note.

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.
The difference is when you learn about the warnings. 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.
No proof comes back. A mock proof cannot be submitted.

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.
Show users quote(), not simulated gas. Gas is not the cost that leaves their shielded balance.
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:
For preconditions the client cannot know about, wait explicitly and pass the result as 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

  1. Leave OHTTP on. Disabling it exposes your viewing key to the service operators.
  2. Withdraw to a fresh address. Reusing the deposit address links both ends of the pool and undoes the privacy you paid for.
  3. Show quote() before the user commits, and never present simulated gas as the total cost.
  4. Show wait.onAttempt. A silent wait of minutes looks like a hang. wait also carries depth, pollIntervalMs and timeoutMs for chains the two-second default does not suit.
  5. Never persist the viewing key. It is reproducible from the signing key; storing it only creates something to leak. wallet.disconnect() revokes it; call revokePrivacy() yourself if you built the client with createPrivacy.
  6. 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.
  7. Keep prover and discovery URLs out of version control. Configure them per environment.
  8. Check the recipient’s channel before offering a transfer so users get a clear reason instead of a build failure. discoverChannels, not discoverRequirement — see above.
  9. Keep invoke for 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 mentions read: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 implement deriveViewingKey. 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:
Do not treat every failure as a screening verdict. The same error code is also used for non-pool transactions and internal faults.

Paymaster rejected the transaction

Starkzap reports the paymaster’s own message and the reason from its data. 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 use https://, 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 through await 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