> ## Documentation Index
> Fetch the complete documentation index at: https://docs.starknet.io/llms.txt
> Use this file to discover all available pages before exploring further.

# STRK20 privacy pool

> Deposit, transfer, and withdraw privately through the STRK20 privacy pool—private notes, a remote proving service, and relayer submission that keeps your account off-chain

## 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.

<Warning>
  **Deposits require access from StarkWare.** Every deposit is screened against sanctions lists through [Elliptic](https://www.elliptic.co/). 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](#running-your-own-services). The SDK is a prerelease (`0.14.x-rc`) published to GitHub Packages and requires Node 24 or later.
</Warning>

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](/build/starkzap/tx-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](/build/starkzap/privacy/overview).

## 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`:

```txt theme={null}
@starkware-libs:registry=https://npm.pkg.github.com
```

Reads require authentication even though the package is public. Put a token with `read:packages` in your **`~/.npmrc`** — never in the committed project file:

```txt theme={null}
//npm.pkg.github.com/:_authToken=YOUR_TOKEN
```

Then install:

```bash theme={null}
npm install @starkware-libs/starknet-privacy-sdk
```

<Note>
  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](/build/starkzap/react-native).
</Note>

### 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:

```typescript theme={null}
import { deriveAccountLeafViewingKey, type ViewingKeyContext } from "starkzap";

class MySigner implements SignerInterface {
  async deriveViewingKey(context: ViewingKeyContext): Promise<string> {
    return deriveAccountLeafViewingKey(this.privateKey, context);
  }
}
```

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.

<Note>
  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.
</Note>

```typescript theme={null}
import { StarkZap } from "starkzap";
import { connectPrivacy } from "starkzap/privacy";

const sdk = new StarkZap({ network: "mainnet" });
const wallet = await sdk.connectWallet({ account: { signer } });

const privacy = await connectPrivacy(wallet, {
  poolContractAddress: POOL,
  prover: PROVER,
  discovery: DISCOVERY,
  paymaster: {
    url: "https://my-app.example.com/api/paymaster/mainnet",
    fee: { mode: "sponsored" },
    // Ceiling on what one quote may withdraw for its fee, in base units of the
    // fee token, and the forwarder addresses allowed to receive it. Both come
    // from your paymaster operator.
    maxFee: 10n ** 19n,
    allowedFeeRecipients: [FORWARDER],
  },
});
```

`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.

| Field                  | Required    | Purpose                                                                                                                                                                                                  |
| ---------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `poolContractAddress`  | Yes         | The pool contract. Folded into the viewing key as a felt, so `0x040…` and `0x40…` are the same pool.                                                                                                     |
| `prover`               | Yes         | Proving service base URL, or a `ProofProviderInterface` instance. A prover you host covers transfers and withdrawals. **Deposits** also need the screening step, which only StarkWare's prover performs. |
| `discovery`            | Yes         | Discovery service base URL, or a `DiscoveryProviderInterface` instance. You can host this yourself.                                                                                                      |
| `paymaster`            | In practice | Where and how private transactions are submitted. See the table below.                                                                                                                                   |
| `ohttp`                | No          | Envelope-encrypts requests to the two services. Defaults to on.                                                                                                                                          |
| `viewingKeyDerivation` | No          | Replace the default derivation scheme.                                                                                                                                                                   |

The `paymaster` block:

| Field                  | Required | Purpose                                                                                                                                                                                                                                                                                 |
| ---------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url`                  | Yes      | Endpoint that submits private transactions. Point it at a proxy that holds your API key, not at the paymaster directly. Use one proxy per network, because each AVNU deployment whitelists only its own pool.                                                                           |
| `fee`                  | Yes      | How the pool fee is paid. There is no default. See [Fee modes](#fee-modes).                                                                                                                                                                                                             |
| `tip`                  | No       | Transaction priority: `"slow"`, `"normal"`, or `"fast"`. AVNU fills in `"normal"` when omitted.                                                                                                                                                                                         |
| `maxFee`               | Yes      | Refuse a quote whose fee exceeds this, in base units of the fee token. This is the only limit on what a bad endpoint can withdraw from your shielded balance. There is no default. Size it for your fee mode, see [Fee modes](#fee-modes).                                              |
| `allowedFeeRecipients` | Yes      | Forwarder addresses allowed to receive the fee. A quote naming any other address is refused. An empty list refuses every quote. Get the address from your paymaster operator, per network, and update it when they rotate the forwarder. Compared by value, so padding does not matter. |
| `fetch`                | No       | Transport override. Defaults to the global `fetch`. Wrap it to add auth headers, cookies, retries, a timeout or tracing.                                                                                                                                                                |

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.

| Component         | Where to get it                                                                                                                                                                                                                                                                                                                                      |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Discovery service | [`deploy/discovery-service/`](https://github.com/starkware-libs/starknet-privacy/tree/main/deploy/discovery-service) in the protocol repo — Dockerfile plus local and multi-platform build instructions. The service itself is [`crates/discovery-service/`](https://github.com/starkware-libs/starknet-privacy/tree/main/crates/discovery-service). |
| Proving service   | [`snip-36-prover-backend`](https://github.com/starknet-innovation/snip-36-prover-backend) — proves [SNIP-36](https://community.starknet.io/t/snip-36-in-protocol-proof-verification/116123) virtual-block execution with the stwo-cairo prover, which is the mechanism the pool's proofs use.                                                        |

Deposits are the exception. The prover does not screen deposits itself. It calls a [`proof-interceptor`](https://github.com/starkware-libs/starknet-privacy/tree/main/proof-interceptor) sidecar, which sends each deposit address to an [`elliptic-proxy`](https://github.com/starkware-libs/starknet-privacy/tree/main/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.

<Note>
  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.
</Note>

### Keep OHTTP on

<Warning>
  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.
</Warning>

Pass `true` for defaults, or an object to route through a relay that also hides your IP from the discovery service:

```typescript theme={null}
ohttp: { relayUrl: OHTTP_RELAY }
```

### 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**.

| Mode                                          | API key  | Cost                                                                                                                                                                          |
| --------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `{ mode: "sponsored" }`                       | Required | Relayer pays gas. Pool fee in STRK. Use this unless you have a reason not to.                                                                                                 |
| `{ mode: "sponsored_private", poolFeeToken }` | Required | As sponsored, but you choose the pool-fee token. Private transactions only.                                                                                                   |
| `{ mode: "default", gasToken }`               | None     | No key needed. The withdrawal is sized at the paymaster's suggested *maximum* gas, not its estimate, so you pay for headroom you may not use. `quote()` reports both figures. |

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.

| Aspect                           | `connectPrivacy`                                          | `createPrivacy`                                   |
| -------------------------------- | --------------------------------------------------------- | ------------------------------------------------- |
| Returns                          | this wrapper: pool fee, proving block, relayed submission | the privacy SDK's own `PrivateTransfersInterface` |
| Submission                       | through the paymaster's relayer                           | yours to arrange                                  |
| Cached per wallet                | Yes                                                       | No. Each call derives the key again               |
| Revoked by `wallet.disconnect()` | Yes                                                       | No. Call `revokePrivacy(transfers)` yourself      |

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](https://github.com/starknet-io/SNIPs/pull/177): 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](#two-ways-in-connectprivacy-and-createprivacy). Neither call scrubs memory, and neither undoes the decryption of notes already discovered.

<Warning>
  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.
</Warning>

### 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.

```typescript theme={null}
import type { ViewingKeyDerivation } from "starkzap/privacy";

const fromWalletKdf: ViewingKeyDerivation = async (context, signer) => {
  // context: { chainId, accountAddress, poolAddress, keyIndex? }
  // signer:  the account's own signer, for schemes that reach key material
  //          through it or delegate to a device
  return myWallet.deriveViewingKey(context); // 0x-prefixed hex
};
```

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.

<Note>
  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.
</Note>

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:

```typescript theme={null}
const requirement = await privacy.discoverRequirement(wallet.address, token.address);
const registered = requirement !== 0; // SetupRequirement.Register === 0
```

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:

```typescript theme={null}
export async function recipientReady(recipient: string): Promise<boolean> {
  const address = recipient.trim();
  try {
    const { channels } = await privacy.discoverChannels([address]);
    return Boolean(channels?.get(address)?.publicKey);
  } catch {
    return false;
  }
}
```

<Warning>
  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.
</Warning>

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.

```typescript theme={null}
import { Amount, fromAddress } from "starkzap";
import { waitForFundedBalance } from "starkzap/privacy";

const amount = Amount.parse("100", token);

const hash = await privacy.send(
  (b) =>
    b
      .with(token.address, (t) => t.deposit({ amount: amount.toBase() }))
      .surplusTo(wallet.address),
  {
    // Relayed alongside the pool action. `calls()` resolves the builder without
    // sending, so every ERC20 and protocol helper is available here.
    invoke: await wallet.tx().approve(token, fromAddress(POOL), amount).calls(),
    autoRegister: true,
    autoSetup: true,
    autoDiscover: { notes: "refresh", channels: "refresh" },
    provingBlockId: await waitForFundedBalance(
      wallet.getProvider(),
      token,
      fromAddress(wallet.address),
      amount.toBase()
    ),
  }
);
```

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.

<Warning>
  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.
</Warning>

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.

```typescript theme={null}
// 1. Public approve so the pool can pull the funds.
const approve = await wallet
  .tx()
  .approve(token, fromAddress(POOL), amount)
  .send();
await approve.wait();

// 2. The private deposit — the same call as above, without `invoke`.
const hash = await privacy.send(
  (b) =>
    b
      .with(token.address, (t) => t.deposit({ amount: amount.toBase() }))
      .surplusTo(wallet.address),
  {
    autoRegister: true,
    autoSetup: true,
    autoDiscover: { notes: "refresh", channels: "refresh" },
    provingBlockId: await waitForFundedBalance(
      wallet.getProvider(),
      token,
      fromAddress(wallet.address),
      amount.toBase()
    ),
  }
);
```

### 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`.

<Note>
  Tongo's `fund()` bundles its own approve, so there is no choice to make there.
</Note>

## Transfer

```typescript theme={null}
const hash = await privacy.send(
  (b) =>
    b
      .with(token.address, (t) =>
        t.transfer({ recipient: recipient.trim(), amount: amount.toBase() })
      )
      .surplusTo(wallet.address),
  {
    autoSetup: true,
    autoSelectNotes: "naive",
    autoDiscover: { notes: "refresh", channels: "refresh" },
  }
);
```

`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

```typescript theme={null}
const hash = await privacy.send(
  (b) =>
    b
      .with(token.address, (t) =>
        t.withdraw({ recipient: recipient.trim(), amount: amount.toBase() })
      )
      .surplusTo(wallet.address),
  {
    autoSelectNotes: "naive",
    autoDiscover: { notes: "refresh", channels: "refresh" },
  }
);
```

Make the recipient an explicit choice in your UI rather than defaulting to the connected wallet.

<Warning>
  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.
</Warning>

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:

```typescript theme={null}
const { notes } = await privacy.discoverNotes();

const balances = tokens.map((token) => {
  const owned = notes.get(BigInt(token.address)) ?? [];
  const total = owned.reduce((sum, note) => sum + note.amount, 0n);
  return {
    token,
    private: Amount.fromRaw(total, token),
    notes: owned.length,
  };
});
```

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.

```typescript theme={null}
const { warnings, feeAction } = await privacy.simulate((b) =>
  b
    .with(token, (t) => t.withdraw({ recipient, amount }))
    .surplusTo(wallet.address)
);

if (warnings.length > 0) {
  // Ask the user. `USER_LINKAGE` is the one to expect on a withdrawal.
}
```

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.

<Note>
  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.
</Note>

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:

```typescript theme={null}
const fee = await privacy.quote();
console.log(fee.feeAction.amount); // bigint, base units of fee.feeAction.token

// What the paymaster reckons the gas costs, when it reports it.
if (fee.gas) {
  console.log(fee.gas.estimatedInGasToken, fee.gas.suggestedMaxInGasToken);
}
```

`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.

<Warning>
  Show users `quote()`, not simulated gas. Gas is not the cost that leaves their shielded balance.
</Warning>

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:

```typescript theme={null}
import type { ProvableAttempt } from "starkzap/privacy";

const hash = await privacy.send(compose, {
  wait: {
    onAttempt: ({ attempt, head, provingBlock, ready }: ProvableAttempt) => {
      if (ready) return;
      console.log(`#${attempt}: head ${head}, proving block ${provingBlock} — polling`);
    },
  },
});
```

For preconditions the client cannot know about, wait explicitly and pass the result as `provingBlockId`:

| Helper                                                 | Waits until                                                    |
| ------------------------------------------------------ | -------------------------------------------------------------- |
| `waitForFundedBalance(provider, token, owner, amount)` | The owner's ERC20 balance covers `amount` at the proving block |
| `waitForDeployedAccount(provider, address)`            | The account is deployed                                        |
| `waitForProvableState(provider, isVisible)`            | Your own predicate holds at the proving block                  |

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](#registering-and-your-viewing-key).
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:

```typescript theme={null}
import { screeningVerdict } from "starkzap/privacy";

switch (screeningVerdict(err)) {
  case "rejected":
    // The source address is blocked. Retrying will not help.
    break;
  case "unavailable":
    // Screening is down and deposits fail closed. Retry later.
    break;
  default:
    // Not a screening failure — surface the underlying 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:

| Code  | Meaning                                                                                                                                                                                                                                                                               |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `151` | The fee token is not supported.                                                                                                                                                                                                                                                       |
| `156` | Execution failure, **including a pool address the paymaster has not whitelisted**. The reason is in `data.execution_error`. With `invoke`, the reason `invalid version` means the account does not support SNIP-9 outside execution. Starkzap rewrites that one into a clear message. |
| `163` | An invalid API key, an unavailable service, or a blacklisted call. This is the generic catch-all, so `data` is what distinguishes them.                                                                                                                                               |
| `165` | The proof is missing the fee withdrawal. Append `feeAction` from `quote()` before proving.                                                                                                                                                                                            |
| `167` | The withdrawn fee is below the quoted pool fee. Quote and prove again.                                                                                                                                                                                                                |
| `168` | On **build**, `sponsored_private` was used for a non-private transaction. On **execute**, the proof is missing. Branch on method and code together.                                                                                                                                   |
| `169` | The transaction exceeds the maximum L2 gas.                                                                                                                                                                                                                                           |

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

* [Choosing a privacy protocol](/build/starkzap/privacy/overview) — STRK20 compared with Tongo
* [Tongo confidential transfers](/build/starkzap/privacy/tongo) — the other privacy protocol
* [Paymasters](/build/starkzap/paymasters) — how relayed submission works
* [Transactions](/build/starkzap/transactions) — execution options
* [API Reference](/build/starkzap/api-reference) — full method signatures
