VOSA: Virtual One-time Sub-Account — A Simplified Privacy Primitive for EVM

VOSA is a privacy pattern that trades fund-flow privacy for simplicity and compliance. Essentially: UTXO without Merkle trees — using O(1) spent-markers with epoch cleanup.

We have a working implementation and are exploring whether this is worth formalizing as an EIP. Looking for feedback.

What is VOSA?

  • Virtual: Addresses exist only as mapping keys, not real EVM accounts

  • One-time: Each address used exactly once (like UTXO notes)

  • Sub-Account: Derived from master key using stealth addresses (ERC-5564)

State is a single flat mapping:


mapping(address => bytes32) balanceCommitmentHash;

// bytes32(0) → never used

// Poseidon(amt, blind, ts) → has balance (timestamp for replay protection)

// 0xDEADDEAD...|block# → spent (prefix + spent block number, deletable after configurable window)

State machine: UNUSED → ACTIVE → SPENT → (cleanup) → UNUSED

No Merkle trees. No nullifier sets. Spent-tracking is O(1) and cleanable.

How It Works

Each VOSA holds a Poseidon hash commitment hiding the balance amount. Operations use standard ECDSA (MetaMask works directly) + Groth16 ZK proofs (on-chain verification):

  • Deposit: Transfer ERC-20 in → ZK proof verifies commitment == Poseidon(amount, blinder, timestamp) → store at fresh VOSA

  • Transfer: ECDSA signature + ZK proof that sum(inputs) == sum(outputs) → mark inputs SPENT, create outputs

  • Withdraw: ECDSA signature + ZK proof → mark SPENT, transfer ERC-20 out

  • Consolidate: ECDSA signatures + ZK proof → merge multiple VOSAs into one (simplifies account management, reduces future gas)

  • Cleanup: SPENT markers encode block.number. After a configurable window (default ~1 month), anyone can call cleanup() to delete expired entries and save gas

Why Poseidon over Pedersen? ~350 constraints vs ~3,400 — proof time drops from ~500ms to ~92ms, storage from 64 bytes to 32 bytes.

Key Design Choice: SPENT_MARKER vs Nullifier

Aspect Traditional UTXO (Tornado/Railgun) VOSA
Double-spend prevention Nullifier set SPENT_MARKER
State lookup O(log n) Merkle proof O(1) mapping
State growth Unbounded (nullifiers forever) Bounded (cleanup)
Fund flow Hidden Visible (by design)
10-year storage (10B txs) ~320 GB ~2.7 GB

VOSA deliberately exposes fund flow in exchange for:

  • ~10x fewer ZK circuit constraints

  • O(1) state operations

  • 97% storage savings with Epoch cleanup

  • Simpler client implementation

Privacy Model

Hidden: :white_check_mark: Amounts, :white_check_mark: Balances, :white_check_mark: Real-world identity of VOSA holders (via stealth addresses)

Visible: :cross_mark: VOSA-to-VOSA transfer graph (input→output links), :cross_mark: Depositor address, :cross_mark: Withdrawal recipient

This is NOT a bug — it’s designed for compliance-friendly privacy.

Performance

Groth16 + snarkjs WASM, Apple M2. Gas includes ~200K for on-chain Groth16 verification.

Operation Proof Time Gas (L2)
Deposit (0→1) ~92ms ~300K
Transfer (1→2) ~95ms ~350K
Transfer (2→2) ~147ms ~370K
Withdraw (partial) ~92ms ~330K
Consolidate (5→1) ~210ms ~475K

Use Cases

  • :white_check_mark: Corporate balance privacy (hide amounts from competitors)

  • :white_check_mark: Personal wallet privacy (hide holdings)

  • :white_check_mark: Compliant private transactions (auditable fund flow)

  • :cross_mark: Full anonymity (use Tornado/Railgun instead)

Questions for Discussion

  1. Is this pattern useful enough to formalize? Or do existing EIPs already cover this?

  2. Is SPENT_MARKER sound? Any security concerns with this approach vs nullifiers?

  3. Standalone or combined? Should VOSA be an Informational EIP (the pattern), with a separate ERC for “VOSA-20” (wrapped private ERC-20)?

  4. Epoch cleanup incentives — Gas savings sufficient motivation, or need additional incentives?

Related Work

  • ERC-5564 (Stealth Addresses)

  • Tornado Cash (Merkle + Nullifier)

  • Railgun (UTXO with full privacy)

  • Aztec (Private L2)

4 Likes

Cool design with interesting properties. How did you compute the 320 GB and 2.7 GB numbers?

10B transactions over 10 years, 32 bytes per entry.

Traditional UTXO (Tornado/Railgun) — 320 GB:

Every transaction adds a nullifier to the set. Nullifiers are never deleted — that’s how double-spend prevention works. After 10B transactions:

10^10 nullifiers × 32 bytes = 320 GB

This grows monotonically forever. There is no cleanup mechanism by design (deleting a nullifier would re-enable spending).

VOSA — 2.7 GB:

VOSA uses SPENT markers instead of nullifiers, and they are cleaned up after a configurable window (default ~1 month / 216,000 blocks). At any point in time, storage only contains: active balances + SPENT markers within the cleanup window.

The steady-state storage is determined by the rolling 1-month window of SPENT entries:

10B transactions / 120 months = ~83.3M entries per month
83.3M × 32 bytes ≈ 2.67 GB

This is the worst-case steady-state — it doesn’t grow with total historical transactions, only with transaction rate. Double the rate, double the storage; but it never accumulates beyond the cleanup window.

(Active VOSAs add some storage on top, but that depends on how many users hold unspent balances at any given time — typically much smaller than the SPENT set.)

2 Likes