Shadow Accounts

A shadow account is a persistent, pseudonymous identity for one user and one dapp. It lets a protocol recognize a returning user and hold positions over time without exposing the public link to that user's main wallet.

A shadow account is not a wallet account: it has no keys and cannot sign. The ShadowAccountAnonymizer deploys it lazily at a deterministic address and is the only contract that can execute calls through it.

Requires starknet@^10.8.0, Wallet API 0.10.4, and a connected wallet that supports the STRK20 shadow-account methods.

Try a working vault integration

The Shadow Account Vault Demo compares public and private deposits into the existing Vesu Prime STRK vault. In private mode, a shadow account owns the vault shares and withdrawals return to the wallet's shielded balance.

Try demo · View source

Public/private Vesu vault integration · Mainnet demo · Real assets. Educational example, not audited production software.

What is private

The link to the user's main wallet is hidden. The shadow account itself is public: its ERC-20 balances, calls, and protocol positions are visible onchain. Use it for unlinkability and stable per-dapp identity, not to hide the contents of the account.

Each (user, dapp_name, nonce) tuple resolves to a different address. Keep the dapp_name stable, and use another nonce when the user wants a separate identity for the same dapp.

Get the commitment

WalletAccountV6 asks the wallet to derive commitments from the user's private state. No transaction is sent and the dapp never receives the viewing key.

// Shared by every shadow account this user derives for this dapp.
const partial = await account.strk20ShadowAccountCommitment("myDapp")

// Selects the shadow account at nonce 0.
const commitment = await account.strk20ShadowAccountCommitment("myDapp", "0x0")

Omitting the nonce returns the partial commitment. Passing "0x0" returns the full commitment for nonce 0; the two calls are not equivalent.

Resolve the address

Use the anonymizer's get_shadow_accounts view when the dapp needs an address before invoking - for example, to fund it from the shielded balance. The view also reports whether each address has already been deployed. This snippet assumes anonymizer is a starknet.js Contract created with the current ShadowAccountAnonymizerABI.

import { num } from "starknet"

const shadowAccounts = await anonymizer.get_shadow_accounts(partial, 0, 5, false)

// [{ nonce, address, is_deployed }, ...]
const shadowAccountAddress = num.toHex(shadowAccounts[0].address)

The range is [start, end) and is limited to 1,024 nonces. Set the final argument to true to stop at the first undeployed nonce and return only the contiguous deployed prefix.

For offline derivation, use the Privacy SDK's shadowAccountAddress helper. The current anonymizer implementation uses the fixed PRIMER_CLASS_HASH; do not substitute the value returned by get_shadow_account_class_hash, because that is the class installed after the deterministic address is chosen.

Invoke through the shadow account

The Wallet API action takes normal starknet.js Call objects. A common flow is:

  1. Withdraw an input token from the shielded balance to the shadow address.
  2. Create one open note for each token that should return to the pool.
  3. Run the calls through shadow_account_invoke.
import type { STRK20_ACTION } from "starknet"

const actions: STRK20_ACTION[] = [
  {
    type: "withdraw",
    token: STRK,
    amount: "0x4563918244f40000",
    recipient: shadowAccountAddress,
  },
  {
    type: "transfer",
    token: STRK,
    amount: "OPEN",
    recipient: userAddress,
  },
  {
    type: "shadow_account_invoke",
    dapp_name: "myDapp",
    nonce: "0x0",
    calls: [stakingContract.populate("stake", { amount: 1000n })],
    collect_policy: { type: "all" },
  },
]

const { transaction_hash } = await account.strk20InvokeTransaction(actions)

The exported STRK20_SHADOW_ACCOUNT_INVOKE_ACTION type is also available when an action is built separately.

Collection policies

One policy applies to every open note settled by the action:

collect_policy Amount collected from the shadow account
{ type: "all" } The token's entire balance
{ type: "diff" } Only the balance gained during this interaction
{ type: "exact", amount } Exactly amount in the token's smallest unit

Things to notice

  • calls must contain at least one standard starknet.js Call.
  • The number of open notes created in the transaction must match the number the shadow-account call fills.
  • A transaction has one invoke-phase slot. invoke and shadow_account_invoke are mutually exclusive in the same transaction.
  • Deployment is lazy: the address can receive funds before the first shadow_account_invoke deploys it.
  • Use the anonymizer address for the connected network from Deployed Contract Addresses.