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. 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.
$$
[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:
-
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.
-
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
proofandproof_facts. Submitting via a JSON-RPC endpoint silently droppedproof_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:
-
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.
-
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.