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.
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:
- Withdraw an input token from the shielded balance to the shadow address.
- Create one open note for each token that should return to the pool.
- 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
callsmust contain at least one standard starknet.jsCall.- 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.
invokeandshadow_account_invokeare mutually exclusive in the same transaction. - Deployment is lazy: the address can receive funds before the first
shadow_account_invokedeploys it. - Use the anonymizer address for the connected network from Deployed Contract Addresses.