RFC 002: Pool asset onboarding
How a new asset (a stablecoin, an LST, a wrapped BTC) is added to the unified shielded pool without forking the protocol or breaking the anonymity set.
Summary
The shielded pool is asset-agnostic by construction — every note carries an asset: [u8; 32] field that is included in the Poseidon commitment (crates/zera-core/src/note.rs). Today only USDC is wired up end-to-end (the constant USDC_MINT lives in constants.rs). This RFC defines the contract for adding a second asset, a third, and so on, without changing the on-chain program, the SDK API surface, or the existing user’s notes.
The crux: the pool itself is one global anonymity set; assets are an attribute on the note, not a separate pool.
Motivation
We have three known consumers asking when a non-USDC asset will be supported:
- A stablecoin issuer who wants to ship a private payments rail for their token.
- A Solana liquid-staking team whose users want shielded LST balances.
- A bridge team wanting wrapped BTC inside the same anonymity set as USDC.
Naive answers are bad:
- “Fork the program for each asset.” Splits the anonymity set per asset; a 1k-note USDC pool plus a 50-note LST pool is a 50-note LST pool from the attacker’s perspective.
- “Wrap everything to a single pool token.” Externalises wrapping risk and forces price oracles into the protocol; the pool is no longer asset-agnostic, just denomination-agnostic-relative-to-the-wrapper.
The pool’s anonymity-set value is destroyed if assets are siloed and degraded if they are wrapped. So the right answer is “one pool, multiple assets, asset is a private attribute of the note”.
Detailed design
The contract
Adding an asset to the pool is the union of three operations:
- On-chain: register the SPL mint with the pool config PDA so that public deposit/withdraw transfers route to the right vault ATA.
- Cryptographic: compute the field-encoded asset identifier (
asset = pubkey_to_field_bytes(mint.to_bytes())) and publish it in the SDK constants. - Off-chain: extend
@zera-labs/sdkand@zera-labs/mcp-serverso a caller can specify which asset they are depositing.
None of these steps require a circuit change. The Groth16 circuits already take asset as a public input (scaffolding post).
On-chain step: pool-config PDA
Today the pool has a single pool_config PDA seeded by POOL_CONFIG_SEED (constants.rs). Proposed change: the pool-config account stores a vector of registered assets:
pub struct PoolConfig {
pub authority: Pubkey,
pub merkle_tree: Pubkey,
pub assets: Vec<RegisteredAsset>, // NEW
pub paused: bool,
}
pub struct RegisteredAsset {
pub mint: Pubkey,
pub vault: Pubkey, // ATA owned by the pool authority PDA
pub decimals: u8,
pub min_deposit: u64, // dust-protection floor in base units
pub enabled: bool,
}
RegisteredAsset.enabled = false is the kill-switch for an asset whose token is freezable (USDC mainnet has freezable mint authority — TODO: Dax confirm whether the pool authority should reject freezable assets at registration time).
Adding an asset is a single instruction register_asset(mint, decimals, min_deposit) callable only by the pool authority. The instruction creates the vault ATA and pushes onto the assets vector. There is no “remove asset” instruction; assets can only be disabled. Disabling an asset prevents new deposits; existing notes for that asset can still be withdrawn (otherwise we would be holding user funds hostage to a registry change).
Cryptographic step: asset as a field element
The Note already carries asset: [u8; 32]. Today it is implicitly USDC because nothing else is registered. The change is purely registration:
- The SDK exports a
getAssetField(mint: PublicKey): Uint8Arrayhelper that reduces a 32-byte mint pubkey into a BN254 field element via the existinghashPubkeyToField(packages/sdk/src/index.tsexports). createNoteis extended so it requires a mint argument and computes the asset field internally. Existing callers passing onlyamountkeep working iff a default-asset env var (ZERA_DEFAULT_MINT) is set; otherwise they get a typed error at construction time.
Notes for different assets have different commitments (because asset is part of the Poseidon hash) but live in the same Merkle tree. That is how the anonymity set stays unified: an attacker watching the chain sees N commitments with no asset distinguisher.
Off-chain step: SDK + MCP surface
The SDK transaction builders add an assetMint parameter:
client.deposit({ amount: 100n, assetMint: USDC_MINT });
client.transfer({ amount: 100n, recipient, assetMint: USDC_MINT });
client.withdraw({ amount: 100n, destination, assetMint: USDC_MINT });
The MCP server gains two new tools:
zera_list_assets— returns the registered assets, their decimals, and their public vault ATAs. Read-only, no proof.zera_balanceis extended so an agent can query the balance for a specific asset, defaulting to the agent’s preferred asset.
zera_deposit, zera_transfer, zera_withdraw get an optional asset argument (string — symbol or mint base58). Backward-compatible; default remains USDC.
Anonymity-set considerations
This is the load-bearing claim of the design: all assets share the same Merkle tree, therefore the same anonymity set.
That claim survives only if:
- The withdrawal transaction does not leak which asset it spends. The withdraw circuit’s public inputs already include the asset field; an observer of a withdraw learns “the user withdrew this much of this asset” but not which deposit produced this note. Anonymity = anonymity-set-of-deposits-of-the-same-asset-value-class. (Same as Tornado Cash’s denomination-class observation; nothing new.)
- The pool does not introduce per-asset balance leaks. The vault ATAs are public on-chain accounts; an observer can compute the rough TVL of each asset by reading the vault balance directly. This is unavoidable. The mitigation is that vault TVL is aggregate, not per-user.
- Asset registration does not become a covert channel. If the pool authority can register/unregister assets at will, an attacker controlling the authority could register an asset, watch a single user deposit it, then unregister it before anyone else does, fingerprinting that user’s traffic. Mitigation: registration is timelock-gated and disabling-only-after-an-N-day-grace-period.
Alternatives considered
A1: Per-asset pool (separate Merkle tree per token)
Pros: simplest implementation, clean separation, easy on-chain accounting. Cons: shatters the anonymity set. A pool with 1k USDC notes and 50 LST notes gives an LST user a 50-note anonymity set, which is unusably small.
Status: rejected.
A2: Universal-wrapper pool token
Mint a zUSD pool token; everything in the pool is denominated in zUSD; deposits convert at oracle price.
Pros: one denomination simplifies UX. Cons: importing oracle risk into the protocol, importing wrapping/unwrapping fees, breaking the “pool is asset-agnostic” abstraction. Also competes with other protocols’ wrapped-stable abstractions.
Status: rejected.
A3: Asset registration permissionless
Anyone can register any SPL mint.
Pros: maximally decentralised. Cons: spam-attackable (attacker registers junk tokens to bloat the pool config), and exposes users to mints with malicious freeze authorities, transfer hooks, or non-standard decimals. The freeze-authority case is particularly nasty: a frozen vault locks everyone’s notes for that asset.
Status: rejected for v1; revisit when SPL Token-2022 transfer-hook semantics are better understood.
A4: Tag the asset in the nullifier instead of the commitment
Put asset inside the nullifier hash but not the commitment. Commitments would be asset-blind.
Pros: even stronger anonymity-set unification at the commitment layer. Cons: spends would have to enumerate-and-prove against every asset’s vault, requiring the circuit to take all asset fields as public inputs. Combinatorial circuit-size explosion. Also: the prover would have to know which asset they are spending to produce the right nullifier, which reintroduces a side channel via timing.
Status: rejected.
Drawbacks
- Circuit re-audit on the asset-handling glue. The circuits already take
assetas a public input, but the constraint that the public asset matches the note’s asset must be exercised by an updated test vector for each new asset. (Adding a new test vector is cheap; what is expensive is convincing an auditor that the constraint actually fires.) - MCP
zera_list_assetsis a fingerprinting risk. An agent is likely running on a user’s machine and might leak the result to a remote model. Mitigation: tools clearly document that the list is public on-chain anyway. - Per-asset min-deposit floors create a usability cliff. A user with 1; they will not understand why.
Open questions
- Decimals normalisation. USDC has 6 decimals; an LST has 9; wrapped BTC has 8. Notes store
amount: u64in base units. Is there any value in the SDK exposing a normalized “human” amount across assets, or should that be the dApp’s problem? TODO: Dax confirm — leaning toward “dApp’s problem”. - Token-2022 transfer hooks. A mint with a transfer hook can run arbitrary code on every transfer. The pool’s deposit instruction would invoke that hook; the withdraw vault would also. Do we accept Token-2022 mints in v1? TODO: Dax confirm — leaning toward “Token-2022 only via case-by-case allowlist”.
- Cross-asset transfers. Should the SDK expose a
swap-and-shieldprimitive that takes asset A in, swaps to asset B inside the pool, and emits a note in asset B? This is a separate RFC because it requires either an on-chain DEX integration or off-chain solver. Out of scope here. - Dust collection. What happens to notes whose asset becomes disabled? Today the answer is “they remain spendable forever, just no new deposits”. Is there a graceful way to consolidate stale-asset dust? TODO: Dax confirm.
Adoption
This RFC is proposed — not yet implemented. The migration order is:
- Land
RegisteredAssetand theregister_assetinstruction in the on-chain program. Existing USDC users see no change; their notes are still valid. - Update
constants.ts/constants.rsto expose a list of registered assets at SDK build time. - Extend the SDK transaction builders with the
assetMintparameter. - Extend the MCP server with
zera_list_assetsand theassetargument on existing tools. - Documented worked example: registering a fictional
zUSD2mint end-to-end.
The acceptance gate for moving from proposed → accepted is the worked example passing in the Surfpool-forked devnet (test suite post).
Security considerations
- Freezable mints. USDC mainnet’s mint authority can freeze any token account, including the pool’s vault. If the pool’s USDC vault is frozen, every USDC note becomes unwithdrawable. This is a known property of USDC and is independent of this RFC. We should disclose it in the README; we should not pretend this RFC fixes it.
- Mint authority changes. A mint can have its authority changed after registration. The pool should re-validate the mint metadata on every withdraw, or accept that this is out of scope and document it. TODO: Dax confirm.
- Vault account creation race.
register_assetcreates the vault ATA. If creation races with a deposit instruction, deposits could fail until the ATA is finalised. Mitigation: register-then-pause-for-N-slots before opening deposits. - Anonymity set is denomination-class-bounded. As with every shielded pool, the anonymity-set size for a given (asset, amount) pair is the bottleneck, not the total note count. Per-asset enabling/disabling does not change this; per-asset siloing would have made it worse.
References
crates/zera-core/src/note.rs— Note struct with theassetfield.crates/zera-core/src/constants.rs— current asset constants (USDC only).packages/sdk/src/index.ts— SDK barrel exports (hashPubkeyToField,createNote).packages/mcp-server/README.md— current MCP tool surface.- RFC 001: zera-sdk monorepo shape — predecessor that explains why the SDK and MCP server are in lockstep for changes like this.