# Shadow Account Demo [Live demo](https://shadow-account-demo.vercel.app/) A developer example showing how a Starknet shadow account can own a persistent position in the existing Vesu Prime vSTRK vault. The dapp connects to a privacy-enabled wallet through Starknet.js `WalletAccountV6`. The wallet handles private state, proof generation, fees, and submission. The application builds ordinary Starknet calls and hands them to the wallet as native `STRK20_ACTION[]`. > This repository uses live Mainnet contracts and real assets. It is an > educational integration example, not audited production software. ## What this example proves A private DeFi flow does not require the target protocol to add shadow-account support. Vesu is called through its existing vault interface: - shielded STRK is withdrawn to the user's dapp-scoped shadow account; - the shadow account approves and deposits STRK into the Vesu vault; - the resulting vSTRK shares remain owned by that shadow account; - on withdrawal, the shadow account calls the vault and the returned STRK is collected into a new private note. There is no Vesu-specific anonymizer, modified Vesu contract, application backend, dapp-held key, or direct dependency on the Starknet Privacy SDK. ## Why the integration stayed small The prototype—from an empty app to a working vault integration—took roughly two hours with AI-assisted implementation. That speed came from the boundaries of the released infrastructure: 1. **The wallet owns the privacy machinery.** The dapp never handles viewing keys, notes, proofs, or private transaction submission. 2. **The canonical `ShadowAccountAnonymizer` is shared infrastructure.** The application does not deploy a custom anonymizer contract. 3. **The shadow account makes ordinary Starknet calls.** Vesu already exposes the required `approve`, `deposit`, `withdraw`, and `redeem` entrypoints. 4. **The integration is two explicit action arrays.** The important code lives in [`src/lib/actions.ts`](src/lib/actions.ts), without another client abstraction around Starknet.js. The lesson is not that every privacy integration takes two hours. The lesson is that an existing protocol with a standard callable interface can often be used without new Cairo code. ## Architecture ```mermaid flowchart LR W[Ready or Xverse wallet] JS[Starknet.js WalletAccountV6] P[STRK20 privacy pool] A[Canonical ShadowAccountAnonymizer] S[User's dapp-scoped shadow account] V[Vesu Prime vSTRK vault] W -->|private state, proof, approval| JS JS -->|STRK20_ACTION array| P P --> A A -->|deploy or invoke| S S -->|approve, deposit, withdraw| V ``` The shadow account is a persistent public pseudonym. It is derived from the user, `dapp_name`, and nonce, then deployed lazily on its first invocation. The protocol flow avoids exposing the controlling wallet address onchain. ## Deposit: private STRK to a public vault position The deposit is one wallet request containing two STRK20 actions: ```ts const actions: STRK20_ACTION[] = [ { type: 'withdraw', token: STRK_TOKEN, amount, recipient: shadowAddress, }, { type: 'shadow_account_invoke', dapp_name: DAPP_NAME, nonce: '0x0', calls: [ { contractAddress: STRK_TOKEN, entrypoint: 'approve', calldata: [VSTRK_VAULT, amountLow, amountHigh], }, { contractAddress: VSTRK_VAULT, entrypoint: 'deposit', calldata: [amountLow, amountHigh, shadowAddress], }, ], collect_policy: { type: 'exact', amount: '0x0' }, }, ]; const { transaction_hash } = await walletAccount.strk20InvokeTransaction(actions); ``` The first action moves STRK from the shielded balance to the shadow address. The second action executes the normal vault calls from that address. No output is collected because the deposit creates public vSTRK shares that must persist in the shadow account. ## Withdrawal: vault assets back to the private balance The withdrawal opens a private STRK note before invoking the vault: ```ts const actions: STRK20_ACTION[] = [ { type: 'transfer', token: STRK_TOKEN, amount: 'OPEN', recipient: walletAccount.address, }, { type: 'shadow_account_invoke', dapp_name: DAPP_NAME, nonce: '0x0', calls: [ { contractAddress: VSTRK_VAULT, entrypoint: 'withdraw', calldata: [amountLow, amountHigh, shadowAddress, shadowAddress], }, ], collect_policy: { type: 'diff' }, }, ]; const { transaction_hash } = await walletAccount.strk20InvokeTransaction(actions); ``` `collect_policy: { type: 'diff' }` collects only the STRK gained during this interaction. Any STRK that was already present in the shadow account remains there. The open note and vault call are submitted atomically. ## Resolving the shadow address A self-contained `shadow_account_invoke` only needs the dapp name and nonce. This example also needs the address because it must fund the shadow account and read its vSTRK balance. The app asks the wallet for one partial, dapp-scoped commitment and resolves nonce zero through the canonical anonymizer: ```ts const partialCommitment = await walletAccount.strk20ShadowAccountCommitment(DAPP_NAME); const result = await provider.callContract({ contractAddress: SHADOW_ACCOUNT_ANONYMIZER, entrypoint: 'get_shadow_accounts', calldata: [partialCommitment, '0x0', '0x1', '0x0'], }); const shadowAddress = result[2]; ``` The result is cached for the connected wallet session and cleared when the wallet account or network changes. ## Run the example Prerequisites: - Node.js 24.21; - a browser wallet that reports Starknet Wallet API `0.10.4`; - a Mainnet account with shielded STRK; and - a browser-safe Starknet Mainnet RPC endpoint. ```bash npm ci cp .env.example .env.local npm run dev ``` Set `VITE_STARKNET_RPC_URL` in `.env.local`. Variables prefixed with `VITE_` are included in the browser bundle, so do not use a privileged or unrestricted RPC credential. Shielding happens in the wallet before using the dapp. Keep enough shielded STRK for the deposit and the privacy-pool fee displayed by the application. ## Live contracts | Component | Mainnet address | | --- | --- | | STRK | `0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d` | | Vesu Prime vSTRK vault | `0x06d6d2bf905dd199c78f2e421521d8473042737be9f47904e7578536c10f279d` | | Vesu Prime pool | `0x0451fe483d5921a2919ddd81d0de6696669bccdacd859f72a4fba7656b97c3b5` | | ShadowAccountAnonymizer | `0x04f33230dc57855c6e7eabe66dfa0fde82c5458fd0e54827cdb7cb4c474888a7` | | STRK20 privacy pool | `0x040337b1af3c663e86e333bab5a4b28da8d4652a15a69beee2b677776ffe812a` | These are live dependencies, not immutable assumptions. Revalidate them before testing: ```bash npm run verify:contracts npm run verify:vault ``` `verify:vault` checks the network, underlying asset, vault entrypoints, anonymizer-to-pool binding, current pool fee, active Vesu market, and live APY response without sending a transaction. ## Verification ```bash npm run typecheck npm test npm run build npm audit --omit=dev ``` The deterministic suite covers exact token conversion, Wallet API capability handling, shadow-address resolution, and action ordering. Live contract checks run daily in GitHub Actions. The tested dependency row is: | Package | Version | | --- | --- | | `starknet` | `10.8.0` | | `@starknet-io/types-js` | `0.10.4` | | get-starknet discovery and wallet standard | `6.0.6` | | React | `19.3.0` | | Vite | `8.3.1` | | TypeScript | `7.0.2` | ## Privacy boundary The shadow account is not a shielded account: | Protected by the flow | Public onchain | | --- | --- | | Direct controlling-wallet link | Shadow address and deployment | | Ownership of input private notes | STRK and vSTRK balances | | Wallet as transaction sender | Vesu calls and position changes | | Viewing key and note state | Amounts and timing | The application and wallet know which connected session requested the action. RPC, relayer, prover, screening, timing, and amount observations can also create correlation. Do not describe the resulting position as invisible or untraceable. ## Adapt it to another protocol The same pattern fits protocols where a persistent public address must own state, receipt tokens, rewards, or permissions: 1. Choose a unique and stable `dapp_name`. 2. Replace the Vesu contract constants. 3. Validate the target contract, asset, and entrypoints onchain. 4. Put its standard Starknet `Call[]` inside `shadow_account_invoke`. 5. Fund the shadow account with a preceding `withdraw` action when required. 6. For outputs returning to private state, create one `OPEN` note per output token and use the appropriate `collect_policy`. 7. Test account switching, first deployment, repeated use, rejection, proof delay, insufficient private funds, and contract reverts. Use a shared helper anonymizer instead when the operation is stateless and should complete in one atomic round trip. Shadow accounts are most useful when the protocol needs a returning pseudonymous owner. ## Repository map - [`src/App.tsx`](src/App.tsx): wallet lifecycle, live vault reads, deposit, withdrawal, and position UI. - [`src/lib/actions.ts`](src/lib/actions.ts): the literal action arrays. - [`src/lib/resolve-shadow-account.ts`](src/lib/resolve-shadow-account.ts): canonical address resolution. - [`scripts/verify-vault-fixture.mjs`](scripts/verify-vault-fixture.mjs): reproducible Vesu and privacy-infrastructure checks. - [`docs/VAULT_DEMO.md`](docs/VAULT_DEMO.md): deeper walkthrough of the persistent-position flow. - [`docs/DX_FINDINGS.md`](docs/DX_FINDINGS.md): implementation findings and upstream caveats. - [`docs/TEST_PLAN.md`](docs/TEST_PLAN.md): manual wallet test matrix. ## Further reading - [Starknet.js: STRK20 shadow accounts](https://github.com/starknet-io/starknet.js/blob/v10.8.0/www/docs/guides/account/walletAccount.md#strk20-shadow-accounts) - [Starknet Wallet API types](https://github.com/starknet-io/types-js/tree/v0.10.4/src/wallet-api) - [StarkWare ShadowAccountAnonymizer](https://github.com/starkware-libs/starknet-privacy/tree/main/packages/shadow_account_anonymizer) - [Vesu documentation](https://docs.vesu.xyz/) ## License MIT