# Adding confidential payments to an advanced smart account: two ways to use the StarkWare privacy pool, and why we picked the companion

**URL:** <https://community.starknet.io/t/adding-confidential-payments-to-an-advanced-smart-account-two-ways-to-use-the-starkware-privacy-pool-and-why-we-picked-the-companion/116264>\
**Category:** Starknet Technical Development\
**Created:** [July 6, 2026, 7:42am UTC](https://community.starknet.io/t/adding-confidential-payments-to-an-advanced-smart-account-two-ways-to-use-the-starkware-privacy-pool-and-why-we-picked-the-companion/116264 "2026-07-06T07:42:38Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![haycarlitos](https://dub1.discourse-cdn.com/flex005/user_avatar/community.starknet.io/haycarlitos/32/85953_2.png) [@haycarlitos](https://community.starknet.io/u/haycarlitos)\
**Post date:** [July 6, 2026, 7:42am UTC](https://community.starknet.io/t/adding-confidential-payments-to-an-advanced-smart-account-two-ways-to-use-the-starkware-privacy-pool-and-why-we-picked-the-companion/116264/1 "2026-07-06T07:42:38Z")

</div>

**TL;DR:** The official StarkWare STRK20 confidential pool authenticates the depositor by calling SNIP-6 `is_valid_signature` on their account. Advanced smart accounts that don’t implement that method (ours is outside-execution-only, with `_*validate*_` deliberately disabled) can’t be the pool’s `user_addr` directly. There are two ways to bridge that gap, and we shipped the one that adds **zero new on-chain audit surface**. Along the way we hit one finding that matters to **anyone** building privacy on account abstraction: **your privacy spending key must not be derived from a key that rotates on recovery.**

This is a field report, not a proposal. We think both findings are reusable, so we’re writing them down.

* * *

## The setup

We run a self-custodial smart-account wallet (passkey/WebAuthn signers, multi-owner, guardian recovery, gasless outside-execution). The account design, and its mainnet-declared, **non-upgradable** class hashes, are described in our SNIP proposal, [Pluggable Signer Interface for Smart Accounts](https://community.starknet.io/t/snip-pluggable-signer-interface-for-smart-accounts/116190). We wanted to add a “private balance”: shield and unshield USDC through the official confidential pool.

The pool expects the depositor (`user_addr`) to be a normal SNIP-6 account. During its validation it calls `is_valid_signature(hash, sig)` on your account and expects the `VALID` magic value back. A standard OpenZeppelin account has this natively. Our account does not: it has no `is_valid_signature`, and its `_*validate*_` reverts by design (all authorization flows through outside-execution). So the pool rejects our account as `user_addr`.

Worth stating plainly: **this is because our account is advanced, not because the pool is limited.** The pool is doing the correct, standard thing.

## The two options

**Option A. Native: add `is_valid_signature` to our account.**

Implement the SNIP-6 method (reusing our existing signer-verification path), redeclare the account class, and migrate users to it.

**Option B. Companion: put a standard account in front of the pool.**

Keep our account untouched. Derive a **deterministic, self-custodial standard sub-account** (“shield account”) that **does** have `is_valid_signature`. Funds flow from our wallet, to the shield account, to the pool. The shield account is a pure pass-through: it nets to \*\*$0 at rest\*\*. The private balance lives in the pool.

```auto

$$
[our wallet] --gasless--> [shield account (standard, is_valid_signature)] --shield--> [pool: private notes] 
$$

User funds: transient, $0 at rest, self-custodial, deterministic

```

## The comparison

| | Native (add `is_valid_signature`) | Companion (standard sub-account) |
| --- | --- | --- |
| New contract to audit | Yes, touches the signing path on a non-upgradable class | **No** , standard OZ class, already audited |
| Re-audit required | Full re-audit of the class | None |
| User migration | Yes, mass migrate to the new class | None |
| Funds custody | Direct | Transient pass-through, **$0 at rest** , self-custodial |
| Operational plumbing (relayer, prover, gateway) | Same | Same |
| What the user sees | Nothing different | Nothing different (companion is invisible plumbing) |
| Time to ship | Weeks, plus audit and migration | **Shipped** |

The key row is the last one. **The companion is already live on mainnet:** the first real confidential shield was tx `0x31f84064cff15b6278acd2f949799e0880d4ed1edde11696d543d4e1e4a6c8f` (SUCCEEDED, 3 pool events). Official pool SDK, our prover, and a standard account, with our main class untouched.

## Why we chose the companion

When we lined the options up honestly, native support **removes one transient, $0-at-rest, self-custodial account built from an already-audited class**. In exchange, it asks us to modify the audited signature-verification path on a **non-upgradable** class, pass a **full re-audit** , and run a **user migration**. It keeps every bit of the operational plumbing either way (the relayer, prover, and gateway submission are identical).

That is a lot of irreversible risk to delete a piece of invisible plumbing that already nets to zero. The risk/reward was inverted. So our choice was: **companion now; native only if a concrete need ever appears** (for example, a partner protocol that specifically needs our account to be SNIP-6 directly). If we ever do it, it is a tiny, focused delta (just `is_valid_signature` plus a shared verify path), not a privacy subsystem.

There is also a UX cost that is specific to making privacy **native and on by default** , and it is the one that decided it for us: **multi-device**. Our multi-device story is “add each new device as an independent owner, with no shared key material.” A privacy spending key is the opposite: one stable secret that every device must share and that must survive recovery. Make privacy default, and every wallet has to carry that shared secret, so every “add a device” flow has to propagate it, for all users. A freshly added, independent device would authorize wallet transactions but be blind to the private balance until the seed reaches it, which turns a one-tap action into a key-transport problem. The companion scopes that cost to the feature: the default multi-device experience stays frictionless, and only users who opt into a private balance take on the “your private balance follows your synced passkey” constraint. That is also why we gate shielding to durable-anchor users at launch.

**Takeaway for other builders:** if you have an advanced or non-standard account and you need to interact with a protocol that assumes SNIP-6, a deterministic standard companion account is a clean, low-risk bridge. You don’t have to compromise your main account’s design or redeclare it.

## The finding that outlives this decision: recovery-stable privacy keys

This one is independent of native-vs-companion and applies to **any** privacy system built on account abstraction.

The pool’s private balance is controlled by a **spending key**. The obvious implementation derives it from a signature by the account’s owner key. That is a **bug** , and here is why:

- Owner keys on a smart account are **designed to rotate** ; that is the whole point of social recovery. On a lost-device recovery, the old owner key is gone forever.

- A privacy spending key must be **stable forever**. If it changes, every note encrypted to the old key becomes **permanently unspendable.**

Couple the two, and a routine guardian recovery silently strands the user’s entire private balance. No one can recover it, because the key it was derived from no longer exists.

**The fix is to decouple them.** Generate the privacy seed independently of any owner or signing key, and anchor it to something **durable that survives device loss**. For us that is the passkey/WebAuthn PRF (it rides the passkey’s platform sync, so it survives losing a device). Store only a public commitment on-chain, never a secret. Owner keys can then rotate freely, and the privacy key is untouched because it was never a function of them.

The same tension has a second face, and it is a UX one: **multi-device**. A smart account’s multi-device model is “each device is an independent owner, no shared key material.” A privacy spending key needs the opposite: one stable secret shared by every device that wants to see the private balance. So the very decoupling that fixes recovery also means a brand-new independent device can authorize transactions while being blind to the private balance until the seed reaches it. Anchoring to a platform-synced passkey PRF is what keeps this one-tap for synced devices; a fresh, independent signer will not see the private balance until it shares that anchor. This is why making privacy a default account feature, rather than an opt-in one, carries a real UX cost: you are asking a “many independent keys” system to also behave like a “one shared secret” system, for every user.

Two honest limits we found while designing the “what if the durable anchor isn’t available” fallback:

1. **Guardian-assisted recovery of a privacy key cannot be made safe against colluding guardians using only a fresh post-recovery key.** Mathematically, anything a threshold of guardians plus a public identity can reconstruct, that same threshold of guardians can reconstruct alone. Making it collusion-resistant **requires** a user-held secret that survived the device loss (a recovery passphrase), and that passphrase must be **high-entropy** , or the guardians can brute-force it offline against the on-chain data.

2. **The durable anchor must be the root, not a cache.** A passkey PRF is a **backup** of the seed, not its owner. Removing that passkey during a legitimate rotation must not destroy the seed, otherwise you have quietly re-introduced the very owner-coupling you removed.

**Launch rule we adopted:** only allow shielding for users whose privacy seed has a durable anchor. Nobody who can’t recover is allowed to create funds they could lose. It is an admission gate, enforced before the first shield.

## Practical integration notes (in case they save someone a week)

These are pool-integration gotchas, not architecture, but they cost us real time:

- The confidential submission needs `proof` **and** `proof_facts`. Submitting via a JSON-RPC endpoint silently dropped `proof_facts` (an empty-proof-facts failure). Submitting via the **sequencer gateway** carried them.

- The signed hash must be the **with-facts** variant (the facts are folded into the v3 hash chain the gateway computes).

- Prover version matters (a proof-format version mismatch reads as an invalid proof), and the proof must be built against a base block a few blocks behind head, because the gateway rejects a base block that is too recent.

## Why we’re posting this

Two things here are reusable, and we would rather they be common knowledge than tribal:

1. **The companion-bridge pattern** (advanced account plus deterministic standard sub-account) as the low-risk way to use a SNIP-6-assuming protocol without compromising or redeclaring your main account.

2. **Recovery-stable privacy keys.** Decouple the spending key from rotating owner keys and anchor it to a durable secret. This is a general tension between account abstraction (keys rotate) and privacy (keys must be stable), and it will bite every AA-meets-privacy design on Starknet that doesn’t handle it up front.

Happy to go deeper on any part. If there is appetite, the “advanced account ↔ SNIP-6 protocol” bridge might be worth a small interop note or standard.
