Repository navigation
Shadow accounts: move STRK20 sub-accounts to SDK rc.8 and Wallet API 0.10.4, and add recovery #443
Description
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
Delegatedpolicy).
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 keysubAccountAnonymizerAddress→shadowAccountAnonymizerAddress;SubAccount*→ShadowAccount*. New exports:shadowAccountAddress,shadowAccountCommitment,shadowAccountPartialCommitment,PRIMER_CLASS_HASH,ShadowAccountAnonymizerABI. - Wire (Wallet API 0.10.4):
wallet_strk20ShadowAccountCommitmentandshadow_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 inscripts/check-privacy-sdk.ts. - Change the smoke test's factory key to
shadowAccountAnonymizerAddress. Add runtime checks forshadowAccounts,partialCommitmentandshadowAccountAddress; today the gate would pass even if these were renamed. - Update the narrow SDK types in
packages/features/privacy/src/sdk/official-sdk-runtime.tsandapps/extension/src/types/privacy-sdk-module.d.ts. -
subaccounts(→shadowAccounts(inofficial-sdk-builder.ts:76andofficial-sdk-reads.ts:135. Rename the factory key inofficial-sdk-transfers.ts:73. -
simulate({ provider })→simulate({ node })inofficial-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.tsandstrk20-bridge.ts,provider/{methods,bridge,results}.ts:wallet_strk20SubaccountCommitment→wallet_strk20ShadowAccountCommitmentsubaccount_invoke→shadow_account_invokewallet.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_MESSAGESis 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 innormalizeStoredPreferences(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-pinEXAMPLE_COM_SCOPEby deriving it independently, and keep the 30-hex-character short-string limit (decision 2). - Rename
strk20-subaccount-*.test.tsand 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_pointinsideshadow_account_invokecalls. 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 usehash.getSelector. - Allow at most one invoke-type action (
invokeorshadow_account_invoke) per batch. - Refuse a public
depositcombined with a shadow invoke that settles open notes (MULTIPLE_SCREENING_SUBJECTS). - Open notes must match the invoke: at most one
OPENtransfer 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:324and 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 getNOT_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
withdrawto 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 Labscenarios-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_MIGRATIONSand 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.
- This needs a vault schema bump from 10 to 11, with one step added to
- 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.
- List accounts with
- Sweep: one
OPENtransfer per token with a non-zero balance, plus a shadow invoke withcollect_policy: alland emptycalls(the anonymizer accepts this). The relay fee comes from the private balance. - Add the new data to
threat-model.mdand the key-boundary tests.
PR 6: bundled defaults and a live check
- Add
shadowAccountAnonymizerAddress: 0x4f33230dc57855c6e7eabe66dfa0fde82c5458fd0e54827cdb7cb4c474888a7toDEFAULT_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 ofDelegatedscreening 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)
- Old wire names: drop them with no aliases. Old-name dapps would target an incompatible anonymizer anyway, and aliases double the surface to audit.
SCOPE_DOMAIN: rename it now. The new anonymizer address already changes every identity, so this is the last break that costs nothing.- 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. - 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/proverwithout inspecting it and records no prover or proof-interceptor version. Confirm that it screensDelegateddeposits using the primer-pattern shadow-account address. Upstream, that is proof-interceptor RC.6 or later; RC.7 or later also requiresSCREENING_ANONYMIZER_ADDRESSset to the mainnet anonymizer. Without it, settling into open notes fails withSCREENING_REQUIREDorSCREENING_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_actionswithin the 30 s paymaster timeout.
Activity
derek-flossman commentedon Oct 13, 2026
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:
SCOPE_DOMAINnow;subaccountAnonymizerAddressrather than carry it over;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.
derek-flossman commentedon Oct 14, 2026
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).
derek-flossman commentedon Oct 14, 2026
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.