Skip to content

Shadow accounts: move STRK20 sub-accounts to SDK rc.8 and Wallet API 0.10.4, and add recovery #443

Closed

Description

@adrienlacombe

Summary

Bramble already ships shadow accounts under their pre-rename name, "sub-accounts": wallet_strk20SubaccountCommitment, subaccount_invoke with collect_policy, origin-bound scopes, and a per-network subaccountAnonymizerAddress. It runs on privacy SDK 0.14.3-rc.4 (722d1cfb).

Upstream has since changed three things:

  • It renamed the feature to shadow accounts (starknet-specs#406).
  • It moved account deployment to a fixed "primer" class, which changes every address.
  • It screens deposits from a shadow account against the shadow account's own address (the Delegated policy).

For Bramble, shipping shadow accounts means: SDK rc.8, the Wallet API 0.10.4 names, checks that refuse doomed batches before the user approves, and a way to recover funds.

The mainnet contracts are in place, and mc-backend already forwards shadow invokes (see Verified). Screening at the prover behind mc-backend is still unconfirmed. The wallet is the remaining work.

Upstream refs: shadow_account_anonymizer.cairo · client/src/shadow-accounts.ts · SDK shadow-account-address.ts

Wallet line references are at the tip of the open stack (#427, 6ae09afa), assuming the stack is merged.

Verified (2026-09-24)

What Result How
Mainnet pool 0x040337b1…812a get_version = '2.1'. get_open_note_screening_policy exists; is_open_note_depositor_blocked is gone. starknet_call on the public RPC
Mainnet shadow-account anonymizer 0x4f33230dc57855c6e7eabe66dfa0fde82c5458fd0e54827cdb7cb4c474888a7, set to Delegated (0x2) at block 15337340. get_privacy_contract returns the pool. It has get_shadow_accounts and no get_sub_accounts. Shadow class hash 0x70e76435b6ddb74b11665d3bc3264aaf354f59329976f3ffcb03b2ab992b78f. OpenNoteScreeningPolicySet events from the pool's deploy block, then view calls
Other policy entries The same block set five more contracts to Exempt. None of them is an anonymizer; they are likely dapp executors. Same scan
Primer class 0x00123e6b…d300 Declared on mainnet starknet_getClass
Address derivation The rc.8 SDK formula matches on-chain get_shadow_accounts(0x1, 0, 2, false) for nonces 0 and 1 Local calculateContractAddressFromHash compared with the view
mc-backend main (#40 merged) The paymaster relay checks pool, apply_actions, proof and fee mode, but never decodes the actions, so shadow invokes pass. Every request body is capped at 4 MiB. src/gateway/{paymaster,routes,mod}.rs
Pool screening rule A public deposit screens the depositor (privacy.cairo:874). A Delegated open-note deposit screens the shadow account (:1019). Mixing both in one batch reverts with MULTIPLE_SCREENING_SUBJECTS. Source
SDK One invoke-type action per transaction (compiler.ts:455). simulate({provider}) is now simulate({node}). partialCommitment() needs only the viewing key, not note discovery. The anonymizer accepts empty calls (Cairo tests). Source at b40bf10
Wallet gaps A hex entry_point is accepted (ENTRY_POINT_PATTERN, wallet-protocol/src/privacy/strk20.ts:24), but the SDK resolves selectors with getSelectorFromName. There is no per-type action count. The anonymizer config is not checked before the approval is queued (strk20-ops.ts:324). A stored preferences record that fails the guard throws Invalid stored preferences. Source

What changed upstream (rc.4 → rc.8)

  • Names: subaccounts() → shadowAccounts(); factory key subAccountAnonymizerAddress → shadowAccountAnonymizerAddress; SubAccount* → ShadowAccount*. New exports: shadowAccountAddress, shadowAccountCommitment, shadowAccountPartialCommitment, PRIMER_CLASS_HASH, ShadowAccountAnonymizerABI.
  • Wire (Wallet API 0.10.4): wallet_strk20ShadowAccountCommitment and shadow_account_invoke. The fields are unchanged.
  • Commitments: the formula is unchanged: identity_key = H("IDENTITY_KEY_TAG:V1", user, viewingKey, anonymizer), partial = H(identity_key, dapp_name), commitment = H(partial, nonce). The anonymizer address is an input, so a new anonymizer gives everyone new identities. Addresses also moved because of the primer pattern. Old sub-accounts can only be reached through their old anonymizer.
  • Screening: open-note deposits from a shadow account are screened on the shadow account's address, with the attestation produced by the prover's proof-interceptor.
  • Other breaking changes: SimulateOptions.provider → node. The screening error messages lost their "Deposit" prefix. New prover refusal reasons are passed through unmapped: multiple_screening_subjects, unknown_delegated_depositor, undeposited_open_notes, shadow_account_undetermined.

Plan (stacked PRs, each green on bun run check)

PR 1: SDK bump rc.4 → rc.8, with no change visible to dapps

  • PRIVACY_SDK_REVISION → b40bf109c8e5b97d9d6e8ec6db48b16b9d10a106 (scripts/vendor-privacy-sdk.ts:26). Update both digests in scripts/check-privacy-sdk.ts.
  • Change the smoke test's factory key to shadowAccountAnonymizerAddress. Add runtime checks for shadowAccounts, partialCommitment and shadowAccountAddress; today the gate would pass even if these were renamed.
  • Update the narrow SDK types in packages/features/privacy/src/sdk/official-sdk-runtime.ts and apps/extension/src/types/privacy-sdk-module.d.ts.
  • subaccounts( → shadowAccounts( in official-sdk-builder.ts:76 and official-sdk-reads.ts:135. Rename the factory key in official-sdk-transfers.ts:73.
  • simulate({ provider }) → simulate({ node }) in official-sdk-preparation.ts:124.
  • Map the unmapped prover reasons to specific fail-closed wallet errors. Stop matching "Deposit screening" message text.
  • Tests: packages/features/privacy/tests/sdk/*, tests/scripts/privacy-provenance-pin.test.ts.

PR 2: rename to the Wallet API 0.10.4 names

  • Rename across packages/foundation/wallet-protocol/src/privacy/strk20.ts and strk20-bridge.ts, provider/{methods,bridge,results}.ts:
    • wallet_strk20SubaccountCommitment → wallet_strk20ShadowAccountCommitment
    • subaccount_invoke → shadow_account_invoke
    • wallet.strk20SubaccountCommitment → wallet.strk20ShadowAccountCommitment
    • approval kind strk20-subaccount → strk20-shadow-account
  • Update the key-boundary catalog (tests/support/key-boundary-strk20-messages.ts), the conformance RPC-spec row and coverage guard, and the popup kind allowlist. DAPP_MESSAGES is derived and updates itself.
  • Rename the config key subaccountAnonymizerAddress → shadowAccountAnonymizerAddress (packages/features/privacy/src/config.ts:49, preferences.ts:150), plus the settings UI label and hint.
  • Migration trap: the config guard rejects unknown keys, so an old stored record would throw Invalid stored preferences. Handle the old key in normalizeStoredPreferences (preferences-stored.ts:286), before validation, with tests for old, current and invalid records. Proposed: drop the old value (decision 3).
  • SCOPE_DOMAIN → bramble/strk20-shadow-account/v1 (apps/extension/src/background/privacy/strk20-subaccount.ts:26; rename the file too). Re-pin EXAMPLE_COM_SCOPE by deriving it independently, and keep the 30-hex-character short-string limit (decision 2).
  • Rename strk20-subaccount-*.test.ts and its fixtures to match.

PR 3: refuse doomed batches before the user approves

Each of these currently fails only after the user has approved, and some would cost a relay fee.

  • Security: refuse a 0x… entry_point inside shadow_account_invoke calls. The SDK hashes it as if it were a name, so the call the user reviews is not the call that runs; in practice it reverts. Also file an upstream issue asking the SDK to use hash.getSelector.
  • Allow at most one invoke-type action (invoke or shadow_account_invoke) per batch.
  • Refuse a public deposit combined with a shadow invoke that settles open notes (MULTIPLE_SCREENING_SUBJECTS).
  • Open notes must match the invoke: at most one OPEN transfer per token, none without an invoke, all before the invoke.
  • Cap total shadow-call calldata so prove and execute bodies stay under the gateway's 4 MiB limit. At full width, 16 × 4096 felts is about 4.3 MiB. Measure the proof size to set the budget.
  • Check that the anonymizer is configured before queuing the approval (strk20-ops.ts:324 and the prepare/invoke handlers), not after.
  • Commitment: drop the discoverNotes() round-trip, since rc.8 computes it locally, but keep a registration check so unregistered accounts get NOT_REGISTERED.
  • Tests: packages/foundation/wallet-protocol/tests/strk20.test.ts, tests/background/privacy/*, poisoning-parity suites, official-sdk-read-retries.test.ts.

PR 4: approval card

  • Replace the raw-label summary (packages/features/privacy/src/policy/summaries.ts:26) with "Runs N calls through your shadow account #nonce for origin".
  • Show the anonymizer address, which tokens settle back into private notes, and plain-language wording: whatever the calls leave in the shadow account can only be moved by this wallet on this site.
  • Optional: after the user approves sharing the commitment, keep the partial commitment in session memory, cleared on lock or on account or network switch. The card can then label a withdraw to the user's own shadow address without deriving the viewing key before approval. The dapp already holds this value.
  • Tests: sidepanel-approval-card-kinds.test.ts, approvals.test.ts, UI Lab scenarios-privacy-dapp.ts.

PR 5: record scopes and add native sweep (proposed gate for PR 6)

Scopes are bound to the site. Only Bramble, on that origin and label, can drive a user's shadow accounts. If a dapp disappears, or a diff/exact policy leaves a remainder, funds are stuck without a wallet-side sweep.

  • Keep an encrypted list per account, chain and pool: anonymizer, origin, label and the exact nonces used.
    • This needs a vault schema bump from 10 to 11, with one step added to VAULT_MIGRATIONS and tests for old, current and invalid data.
    • It reveals which dapps someone used privately, so it goes in the vault, never in preferences.
    • Record a scope when the commitment is shared, not only on invoke: dapps can fund a precomputed address before it is deployed.
    • Record exact nonces, not a maximum, because dapps choose nonces.
  • Add a Shadow accounts view:
    • List accounts with get_shadow_accounts, including undeployed nonces, which may already hold funds.
    • Read token balances only when the user asks, because the reads link addresses at the RPC provider. Say so in the UI.
  • Sweep: one OPEN transfer per token with a non-zero balance, plus a shadow invoke with collect_policy: all and empty calls (the anonymizer accepts this). The relay fee comes from the private balance.
  • Add the new data to threat-model.md and the key-boundary tests.

PR 6: bundled defaults and a live check

  • Add shadowAccountAnonymizerAddress: 0x4f33230dc57855c6e7eabe66dfa0fde82c5458fd0e54827cdb7cb4c474888a7 to DEFAULT_STRK20_NETWORKS.SN_MAIN. Pick it once and never change it: changing it moves every identity, so the old address would have to stay usable for sweeping.
  • Add a local-only mainnet shadow-account lifecycle test next to tests/mainnet/strk20-lifecycle.mainnet.ts: open note → invoke → settle → sweep. This would be the first real run of Delegated screening through mc-backend's prover route.

Every PR updates docs/STRK20_PRIVACY.md, docs/ARCHITECTURE.md, docs/security-audit/threat-model.md, CHANGELOG.md and README.md where they describe sub-accounts.

Decisions (recommendation first)

  1. Old wire names: drop them with no aliases. Old-name dapps would target an incompatible anonymizer anyway, and aliases double the surface to audit.
  2. SCOPE_DOMAIN: rename it now. The new anonymizer address already changes every identity, so this is the last break that costs nothing.
  3. Stored subaccountAnonymizerAddress: drop it rather than carry it over. An old-class anonymizer only half-works with the rc.8 SDK: address lookups fail and invokes are misrouted.
  4. PR 5 gates PR 6: don't bundle a mainnet default until users can recover stuck funds.

Open questions / not verified

  • Screening at the prover behind mc-backend: mc-backend forwards /api/strk20/prover without inspecting it and records no prover or proof-interceptor version. Confirm that it screens Delegated deposits using the primer-pattern shadow-account address. Upstream, that is proof-interceptor RC.6 or later; RC.7 or later also requires SCREENING_ANONYMIZER_ADDRESS set to the mainnet anonymizer. Without it, settling into open notes fails with SCREENING_REQUIRED or SCREENING_INVALID_SIGNATURE.
  • No end-to-end mainnet shadow invoke has been run yet.
  • Payload size: proof size, whether 4 MiB leaves room, and whether AVNU accepts large apply_actions within the 30 s paymaster timeout.

Activity

  1. added
    enhancementNew feature or request
    securitySecurity-sensitive changes (keys, permissions, crypto)
    on Sep 24, 2026
  2. derek-flossman commented on Oct 13, 2026

    @derek-flossman
    Collaborator

    Owner decisions, 2026-10-13: ship shadow accounts in 10.6, on condition that the functionality works as defined and is backed by tests. The issue's four recommendations are accepted:

    1. drop the old wire names, with no aliases;
    2. rename SCOPE_DOMAIN now;
    3. drop the stored subaccountAnonymizerAddress rather than carry it over;
    4. PR 5 (recovery and sweep) gates PR 6 (the bundled mainnet default).

    PR 1 (SDK rc.8) landed in #651. PR 3's pre-approval checks are in progress. PRs 2, 4 and 5 are next. PR 6 also waits on confirmation that the prover behind mc-backend screens Delegated deposits for primer-pattern shadow accounts, and on one end-to-end mainnet shadow lifecycle.

  3. derek-flossman commented on Oct 14, 2026

    @derek-flossman
    Collaborator

    PRs 2–5 landed in #658 (020f23e): Wallet API 0.10.4 names, pre-approval refusals, the anonymizer-bound approval card, hold-until-Close notices, vault v12 scope records, the Shadow accounts view and Sweep. Kept open for PR 6 (the bundled mainnet default), which waits on the prover's Delegated-screening confirmation and a live Sepolia, then mainnet, lifecycle (see #655).

  4. derek-flossman commented on Oct 14, 2026

    @derek-flossman
    Collaborator

    Done for v0.10.6. PR 1 merged in #651, PRs 2–5 in #658, and their copy in #659.

    Anonymizer note: shadow accounts ship without a default anonymizer. They work for anyone who sets the shadow-account anonymizer address in Settings → Privacy, and are off otherwise. A mainnet default (the former PR 6) stays off until mc-backend confirms its prover screens Delegated deposits for shadow accounts, and a live Sepolia-then-mainnet shadow-account run passes (tracked in #655). No separate issue or PR is planned for it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestsecuritySecurity-sensitive changes (keys, permissions, crypto)status:in-progressWork in progress

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions