[Draft ERC] VOSA-20 β€” Privacy-Preserving Wrapped ERC-20 Token Standard

Field Value
Title VOSA-20 - Privacy-Preserving Wrapped ERC-20 Token Standard
Status Draft
Type Standards Track
Category ERC
Requires ERC-20, EIP-712, ERC-5564

Abstract

This ERC defines VOSA-20, a standard interface for privacy-preserving wrapped ERC-20 tokens. VOSA-20 enables confidential transfers where transaction amounts and balances are hidden using Poseidon hash commitments, while validity is verified through on-chain zero-knowledge proofs (Groth16).

The standard leverages the VOSA (Virtual One-time Sub-Account) model β€” each private balance lives at a standard 20-byte EVM address that is used exactly once (UTXO semantics), with ownership proven via standard ECDSA signatures (no custom wallet needed).

Privacy Model: VOSA-20 provides Selective Privacy β€” amounts, balances, and real-world identity of VOSA holders (via stealth addresses) are hidden, but the VOSA-to-VOSA transfer graph remains publicly auditable. This deliberate design choice enables regulatory compliance while protecting financial privacy.

Key Features:

  • Arbitrary transfer amounts (not fixed denominations)
  • On-chain ZK proof verification (fully decentralized)
  • Standard 20-byte addresses + ECDSA signatures (EVM-native, MetaMask compatible)
  • O(1) spent-tracking with configurable epoch cleanup (bounded state growth)

Motivation

The Need for Private Token Transfers

Current ERC-20 tokens expose all transaction details β€” sender/receiver addresses, transfer amounts, and complete transaction history. This creates privacy concerns for salary payments, business transactions, and personal finances.

Privacy Comparison

Solution Amount Privacy VOSA Holder Identity Transfer Graph Compliance
ERC-20 :cross_mark: :cross_mark: :cross_mark: :white_check_mark: Transparent
VOSA-20 :white_check_mark: :white_check_mark: :cross_mark: (by design) :white_check_mark: Auditable
Tornado/Railgun :white_check_mark: :white_check_mark: :white_check_mark: :cross_mark: Difficult

Design Philosophy

VOSA-20 targets Balance Privacy, not Anonymous Mixing:

  • :white_check_mark: Corporate financial privacy (hide amounts from competitors)
  • :white_check_mark: Personal balance privacy (hide wallet holdings)
  • :white_check_mark: Compliant private transactions (satisfy KYC/AML)
  • :cross_mark: Full anonymity requirements (transfer graph is traceable)

Specification

The key words β€œMUST”, β€œMUST NOT”, β€œREQUIRED”, β€œSHALL”, β€œSHALL NOT”, β€œSHOULD”, β€œSHOULD NOT”, β€œRECOMMENDED”, β€œNOT RECOMMENDED”, β€œMAY”, and β€œOPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Overview

ERC-20 (public)  ──deposit()──▢  VOSA-20 (private)  ──withdraw()──▢  ERC-20 (public)
                                    β”‚
                                    β”œβ”€β”€ transfer()      (private β†’ private, amounts hidden)
                                    β”œβ”€β”€ consolidate()   (merge N VOSAs β†’ 1)
                                    β”œβ”€β”€ createPolicy()  (recurring authorization)
                                    └── createPermit()  (one-time authorization)

On-chain state is a single mapping:

mapping(address => bytes32) balanceCommitmentHash;
// bytes32(0)                    β†’ never used
// Poseidon(amt, blind, ts)      β†’ has balance (commitment hides amount)
// SPENT_PREFIX | block.number   β†’ spent (deletable after configurable window)

Dependencies

  • ERC-20: Underlying token standard
  • EIP-712: Typed structured data signing
  • ERC-5564: Stealth address standard

Constants

/// @notice Spent marker prefix (high 128 bits)
bytes16 public constant SPENT_PREFIX = 0xDEADDEADDEADDEADDEADDEADDEADDEAD;

/// @notice Maximum inputs per transaction (DoS protection)
uint256 public constant MAX_INPUTS = 10;

/// @notice Maximum outputs per transaction
uint256 public constant MAX_OUTPUTS = 10;

Core Interface

Metadata

interface IVOSA20Metadata {
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function decimals() external view returns (uint8);
    function underlying() external view returns (IERC20);
    function totalSupply() external view returns (uint256);
    function DOMAIN_SEPARATOR() external view returns (bytes32);
}

VOSA State Management

interface IVOSA20VOSA {
    /// @return bytes32(0) if unused, SPENT_MARKER if spent, commitment if active
    function balanceCommitmentHash(address vosa) external view returns (bytes32);
    function hasBalance(address vosa) external view returns (bool);
    function isEverUsed(address vosa) external view returns (bool);
    function batchGetCommitmentHash(address[] calldata vosas) external view returns (bytes32[] memory);
    /// @return 0=normal, 1=Policy, 2=Permit
    function getAddressType(address vosa) external view returns (uint8);
}

Core Operations

interface IVOSA20Core {
    /// @notice Wrap ERC-20 into private VOSA balance
    function deposit(
        uint256 amount, address recipient, bytes32 commitment,
        uint256 outputTimestamp, bytes calldata ephemeralPubKey,
        bytes calldata proof, bytes calldata memo
    ) external returns (bool);

    /// @notice Private transfer (N inputs β†’ M outputs, amounts hidden)
    function transfer(
        address[] calldata inputs, bytes32[] calldata inputCommitments,
        address[] calldata outputs, bytes32[] calldata outputCommitments,
        uint256[] calldata outputTimestamps, bytes[] calldata ephemeralPubKeys,
        bytes[] calldata signatures, bytes calldata proof,
        uint256 deadline, int256[] calldata policyChangeIndices, bytes calldata memo
    ) external returns (bool);

    /// @notice Unwrap to ERC-20 (optionally with change to new VOSA)
    function withdraw(
        address input, bytes32 inputCommitment, uint256 amount,
        address recipient, address changeAddress, bytes32 changeCommitment,
        uint256 changeTimestamp, bytes calldata changeEphemeralPubKey,
        bytes calldata signature, bytes calldata proof,
        uint256 deadline, bool policyChangeToChange
    ) external returns (bool);

    /// @notice Merge multiple VOSAs into one (same owner)
    function consolidate(
        address[] calldata inputs, bytes32[] calldata inputCommitments,
        address output, bytes32 outputCommitment, uint256 outputTimestamp,
        bytes calldata ephemeralPubKey, bytes[] calldata signatures,
        bytes calldata proof, uint256 deadline
    ) external returns (bool);
}

Authorization Interfaces

Policy (Recurring Authorization)

Analogous to ERC-20 approve, but for one-time-use addresses. Owner authorizes a spender; when the spender transfers, the Policy auto-migrates to the change VOSA.

interface IVOSA20Policy {
    struct PolicyMeta {
        address owner;
        address spender;
        uint256 expiry;    // 0 = never expires
        bool revoked;
    }

    function createPolicy(
        CreatePolicyParams calldata params, bytes[] calldata ephemeralPubKeys,
        bytes calldata ownerSignature, bytes calldata proof, bytes calldata memo
    ) external returns (bool);

    function revokePolicy(address policyAddress) external;

    function getPolicy(address policyAddress) external view returns (
        address owner, address spender, uint256 expiry, bool revoked, bool isActive
    );
}

Permit (One-time Authorization)

Single-use authorization. After use, used = true and the Permit is consumed. No migration.

interface IVOSA20Permit {
    struct PermitMeta {
        address owner;
        address spender;
        uint256 expiry;
        bool revoked;
        bool used;
    }

    function createPermit(
        CreatePermitParams calldata params, bytes[] calldata ephemeralPubKeys,
        bytes calldata ownerSignature, bytes calldata proof, bytes calldata memo
    ) external returns (bool);

    function revokePermit(address permitAddress) external;

    function getPermit(address permitAddress) external view returns (
        address owner, address spender, uint256 expiry, bool revoked, bool used, bool isActive
    );
}

Events

interface IVOSA20Events {
    event Deposit(address indexed depositor, address indexed recipient, uint256 amount,
        bytes32 commitment, bytes ephemeralPubKey, bytes memo);
    event Transfer(address[] inputs, address[] outputs, bytes32[] outputCommitments,
        bytes[] ephemeralPubKeys, bytes memo);
    event Withdraw(address indexed input, address indexed recipient, uint256 amount,
        address changeAddress, bytes32 changeCommitment, bytes changeEphemeralPubKey);
    event Consolidate(address[] inputs, address indexed output, bytes32 outputCommitment,
        bytes ephemeralPubKey);
    event PolicyCreated(address indexed policyAddress, address indexed owner,
        address indexed spender, uint256 expiry, bytes32 policyCommitment,
        address changeAddress, bytes32 changeCommitment, bytes[] ephemeralPubKeys, bytes memo);
    event PolicyRevoked(address indexed policyAddress);
    event PolicyMigrated(address indexed oldPolicy, address indexed newPolicy);
    event PermitCreated(address indexed permitAddress, address indexed owner,
        address indexed spender, uint256 expiry, bytes32 permitCommitment,
        address changeAddress, bytes32 changeCommitment, bytes[] ephemeralPubKeys, bytes memo);
    event PermitRevoked(address indexed permitAddress);
    event PermitUsed(address indexed permitAddress);
}

EIP-712 Type Definitions

bytes32 constant TRANSFER_TYPEHASH = keccak256(
    "Transfer(bytes32 inputsHash,bytes32 inputCommitmentsHash,bytes32 outputsHash,bytes32 outputCommitmentsHash,uint256 deadline)"
);

bytes32 constant WITHDRAW_TYPEHASH = keccak256(
    "Withdraw(address input,bytes32 inputCommitment,uint256 amount,address recipient,address changeAddress,bytes32 changeCommitment,uint256 deadline)"
);

bytes32 constant CONSOLIDATE_TYPEHASH = keccak256(
    "Consolidate(bytes32 inputsHash,bytes32 inputCommitmentsHash,address output,bytes32 outputCommitment,uint256 deadline)"
);

ZK Circuit Specifications

Commitment Format

commitment = Poseidon(amount, blinder, timestamp)

Where:
- amount: uint256, MUST be < 2^96
- blinder: Field element, MUST NOT be 0 (CSPRNG generated)
- timestamp: uint256, for replay protection (validated within Β±2 hour window)

TransferCircuit

For transfer and consolidate β€” verifies balance conservation without revealing amounts:

Public inputs:  txHash, inputCommitments[], outputCommitments[], outputTimestamps[]
Private inputs: inputAmounts[], inputBlinders[], inputTimestamps[], outputAmounts[], outputBlinders[]

Constraints:
  1. Each inputCommitment == Poseidon(inputAmount, inputBlinder, ...)
  2. Each outputCommitment == Poseidon(outputAmount, outputBlinder, outputTimestamp)
  3. sum(inputAmounts) == sum(outputAmounts)
  4. All amounts in [0, 2^96)
  5. All blinders β‰  0

Constraints: ~1,985 (2β†’2), ~2,886 (5β†’1)

AmountCircuit

For deposit and withdraw β€” verifies commitment matches public amount:

Public inputs:  txHash, inputCommitment, outputCommitment, absAmount, isWithdraw, outputTimestamp
Private inputs: inputAmount, inputBlinder, outputAmount, outputBlinder

Constraints:
  Deposit:  absAmount == outputAmount, outputCommitment check
  Withdraw: inputAmount == absAmount + outputAmount, both commitment checks

Constraints: ~782 (deposit), ~1,240 (withdraw with change)

Poseidon Parameters

Hash:   Poseidon
Width:  t = 3 (2 inputs + 1 capacity)
Rounds: RF = 8 full, RP = 57 partial
Field:  BN254 Fr

Authorization Logic

At any moment, each VOSA has exactly one authorized signer β€” zero concurrency conflicts:

function _getAuthorizedSigner(address input) internal view returns (address) {
    PolicyMeta memory pMeta = _policyMeta[input];
    PermitMeta memory tMeta = _permitMeta[input];

    if (pMeta.owner != address(0)) {
        bool isActive = !pMeta.revoked && (pMeta.expiry == 0 || block.timestamp < pMeta.expiry);
        return isActive ? pMeta.spender : pMeta.owner;
    } else if (tMeta.owner != address(0)) {
        bool isActive = !tMeta.used && !tMeta.revoked && (tMeta.expiry == 0 || block.timestamp < tMeta.expiry);
        return isActive ? tMeta.spender : tMeta.owner;
    }

    return input; // Normal VOSA: owner is the address itself
}

Optional Extensions

Auditing Support

Users MAY register auditor public keys. Encrypted memos in transactions allow authorized auditors to decrypt amounts.

interface IVOSA20Auditing {
    function registerAuditor(address account, bytes calldata auditorPubKey, bytes calldata ownerSig) external;
    function removeAuditor(address account, bytes calldata ownerSig) external;
    function getAuditor(address account) external view returns (bytes memory);
    function setGlobalAuditor(bytes calldata auditorPubKey) external;  // owner only
    function globalAuditorKey() external view returns (bytes memory);
}

Fat Token Mode

A single contract with both public ERC-20 and private VOSA layers. Users can shield() public balance into private, or unshield() back. Useful for tokens that want privacy as an opt-in feature.

interface IFatVOSA20 is IERC20 {
    function shield(uint256 amount, address output, bytes32 commitment, ...) external;
    function unshield(address input, bytes32 inputCommitment, uint256 amount, ...) external;
    function publicBalance(address account) external view returns (uint256);
}

Epoch Cleanup

SPENT markers encode the block number when spent: SPENT_PREFIX | uint128(block.number). After a configurable window (default ~216,000 blocks / ~1 month), anyone MAY call cleanup() to delete expired entries and save gas.

interface IVOSA20Cleanup {
    function cleanupWindow() external view returns (uint256);
    function canCleanup(address addr) external view returns (bool);
    function cleanup(address[] calldata addrs) external returns (uint256 cleaned);
    function setCleanupWindow(uint256 window) external;  // owner only
}

Rationale

Why Poseidon Hash?

Hash R1CS Constraints (per commitment) Proof Time (deposit)
SHA256 ~30,000 ~3s
Pedersen ~3,400 ~500ms
Poseidon ~350 ~92ms

Poseidon is ZK-friendly (no curve operations needed), resulting in significantly smaller circuits and faster proofs.

Why Two Verifiers?

Separating circuits optimizes for each use case:

  • AmountCircuit: Simple deposit/withdraw with public amount (~782–1,240 constraints)
  • TransferCircuit: Complex multi-input/output with hidden amounts (~1,985–2,886 constraints)

Why SPENT_MARKER Instead of Nullifiers?

Aspect Nullifier SPENT_MARKER
Transfer graph Hidden Visible
Circuit complexity Higher (Merkle membership proof) Lower
State lookup O(log n) O(1)
State growth Unbounded Bounded (epoch cleanup)
Compliance Difficult Easy

VOSA-20’s design choice: Compliance + simplicity > Full anonymity.

The transfer graph being visible means an observer can see which VOSAs were consumed together β€” but not the amounts. For most use cases (salary privacy, business transactions, personal finance), hiding amounts and balances is what matters. And it makes compliance straightforward.

Backwards Compatibility

  • ERC-20: deposit() uses standard transferFrom; withdraw() uses standard transfer. Underlying token is unchanged.
  • EIP-712: All signatures use typed structured data. Wallets that support EIP-712 (MetaMask, etc.) work out of the box.
  • ERC-5564: VOSA addresses follow the stealth address derivation pattern.
  • Infrastructure: Standard eth_call for queries, event-based indexing, block explorer compatible (amounts hidden in commitments).

Security Considerations

Trusted Setup

Groth16 requires a trusted setup ceremony. RECOMMENDED: Powers of Tau with 100+ participants and published transcripts. Future implementations MAY consider PLONK for universal setup.

Front-Running Protection

  • EIP-712 signatures bind to specific parameters (inputs, outputs, commitments, deadline)
  • Deadline prevents stale transactions
  • ZK proofs require private inputs (blinder) β€” cannot be forged from public data

Double-Spending

Each VOSA is spent exactly once. The SPENT_MARKER is set atomically with ReentrancyGuard. The commitment check balanceCommitmentHash[vosa] == inputCommitment implicitly rejects SPENT (prefix doesn’t match any valid Poseidon hash) and unused addresses (bytes32(0) doesn’t match).

Amount Security

  • Range proofs enforce 0 ≀ amount < 2^96 (prevents negative amounts / overflow)
  • Balance conservation enforced in ZK circuit: sum(inputs) == sum(outputs)
  • Blinder β‰  0 enforced in circuit (prevents commitment from being a simple hash of the amount)

Address Security

  • Address collision probability: ~2^-80 (negligible for 20-byte addresses)
  • Address squatting: An attacker cannot pre-claim a VOSA without knowing the private key
  • Replay protection: DOMAIN_SEPARATOR includes chainId + contract address; each VOSA spent only once

Timestamp Validation

Output timestamps MUST be within [block.timestamp - 2 hours, block.timestamp]. This prevents replay of old transactions while allowing reasonable clock drift.

Reference Implementation

Repository: [GitHub link]

contracts/
β”œβ”€β”€ WrappedVOSA20.sol         # Main contract (EIP-712, Pausable, ReentrancyGuard, Ownable)
β”œβ”€β”€ interfaces/
β”‚   └── IVOSA20.sol            # Full interface
β”œβ”€β”€ libraries/
β”‚   └── Poseidon.sol           # Poseidon hash (BN254 Fr)
└── verifiers/
    β”œβ”€β”€ TransferVerifier.sol    # Groth16 verifier for transfers
    └── AmountVerifier.sol      # Groth16 verifier for deposit/withdraw

circuits/
β”œβ”€β”€ configs/
β”‚   β”œβ”€β”€ amount_0_1.circom      # Deposit (0β†’1)
β”‚   β”œβ”€β”€ amount_1_0.circom      # Full withdraw (1β†’0)
β”‚   β”œβ”€β”€ amount_1_1.circom      # Partial withdraw (1β†’1)
β”‚   β”œβ”€β”€ transfer_1_2.circom    # Split (1β†’2)
β”‚   β”œβ”€β”€ transfer_2_1.circom    # Merge (2β†’1)
β”‚   β”œβ”€β”€ transfer_2_2.circom    # Standard transfer (2β†’2)
β”‚   β”œβ”€β”€ transfer_5_1.circom    # Consolidate (5β†’1)
β”‚   β”œβ”€β”€ transfer_10_1.circom   # Large consolidate (10β†’1)
β”‚   └── ...                    # Additional configurations
└── lib/
    β”œβ”€β”€ chunked_poseidon.circom  # Poseidon hash (chunked for variable inputs)
    β”œβ”€β”€ hash_commitment.circom   # Commitment + range proof (amount < 2^96)
    └── sum.circom               # Balance conservation check

Test Cases

Required coverage:

  1. Deposit: Correct commitment, ZK verification, fee-on-transfer rejection
  2. Transfer: Multi-input/output (1β†’2, 2β†’2, 5β†’1), balance conservation, duplicate/overlap checks
  3. Withdraw: Full and partial withdrawal with change
  4. Consolidate: Multiple VOSAs to one, same-owner enforcement
  5. Policy: Create, use (spender), revoke (owner), expire, auto-migrate to change
  6. Permit: Create, use (one-time), revoke, expire
  7. Cleanup: Epoch window, batch cleanup, cannot clean active VOSAs
  8. Security: Replay prevention, double-spend, front-running, overflow

Copyright

Copyright and related rights waived via CC0.

2 Likes

Hey @louisliu2048, great spec. Built a ZK privacy DEX on Uniswap V4 (Groth16 + Poseidon) so a few quick thoughts:

SPENT_MARKER over nullifiers is the right call for your use case. O(1) + auditable graph > full anonymity when you’re targeting compliance. Smart tradeoff.

Circuit sizes are impressively lean (~782 for deposit). Skipping the Merkle tree entirely pays off.

Have you benchmarked proof gen on mobile/browser? That was our biggest UX bottleneck.

I have two questions:

β€’ The 2-hour timestamp window, could be tight on L2s during congestion. Worth making configurable?

β€’ Epoch cleanup, what happens if a user goes offline longer than the cleanup window?
Fat Token Mode is the killer feature imo. Opt-in privacy on existing tokens is way better for adoption than requiring migration.

Also, i really interested in contributing to this. I’ve been hands-on with Groth16 circuits + Poseidon commitments for the past year. Happy to help with circuit review, testing, or the reference implementation. DM open :handshake:

Thanks for the detailed feedback! Addressing your questions:


Mobile/browser proof generation:

Yes, all our benchmarks use snarkjs WASM which is exactly the browser/mobile runtime. Numbers on Apple M2:

Circuit Proof Time (WASM)
deposit (amount_0_1) ~92ms
transfer 1β†’2 ~125ms
transfer 2β†’2 ~147ms
withdraw with change ~128ms

These are fast enough for interactive UX on modern phones. On older/low-end devices, the main bottleneck is WASM initialization (~300ms cold start), not the proving itself. We haven’t done systematic Android benchmarking yet β€” would welcome data points from anyone who tries.

For server-side/backend use cases, rapidsnark (C++ native prover) cuts prove time to ~45-70ms per circuit.


hour timestamp window on L2s:

Good catch. The TIMESTAMP_WINDOW is currently a compile-time constant (2 hours). In practice, the window only needs to be larger than the maximum expected delay between proof generation and block inclusion. On L2s with fast finality (1-2s blocks), 2 hours is extremely generous β€” even during congestion, transactions either land quickly or get dropped and retried.

That said, making it a runtime-configurable parameter (owner-settable, like cleanupWindow) is a reasonable improvement. We’ll consider it. The key constraint is: too small β†’ UX failures from normal latency; too large β†’ stale proofs can be submitted long after generation.


Epoch cleanup and offline users:

This is the most common concern, but it’s actually a non-issue by design:

Cleanup only deletes SPENT markers, never active balances.

The state machine is: UNUSED β†’ ACTIVE β†’ SPENT β†’ (cleanup) β†’ UNUSED

  • If you have an active VOSA (holding funds), its state is ACTIVE (a Poseidon commitment). Cleanup skips it β€” it only touches entries with the SPENT_PREFIX.

  • If you go offline for 2 years and come back, your VOSA is still there, commitment intact, funds safe.

  • What gets cleaned up are the SPENT entries β€” addresses you’ve already transferred away from. Those are just historical markers that the address was used. Deleting them frees storage and allows the address to theoretically be reused (though collision probability is ~2^-80).

So: no risk of fund loss from being offline. Cleanup is purely a storage optimization for already-consumed UTXOs.


Fat Token Mode:

Agreed β€” we think the conceal()/reveal() pattern is the most practical path to adoption. Users can deposit to CEX in public mode, trade normally, then conceal() when they want privacy. No migration, no wrapping contract, same token address.

1 Like

Thanks for the offer! Great to have someone with hands-on Groth16 + Poseidon experience interested.

The codebase is currently under internal review. We plan to open-source it, but the timeline depends on completing our internal audit and compliance processes. Once open, the repo covers a full product suite built on the VOSA primitive.

Circuit review and testing help would be especially valuable. Will share the repo link here once it’s public. Stay tuned!

1 Like

Great benchmarks! ~92-147ms WASM is interactive-ready.

one more thing, edge case if a client crashes after submitting a transfer but before persisting the new VOSA key material, the change note is lost. Any recovery path planned?

Happy to contribute Android benchmarks + circuit review once the repo is public. :saluting_face:

Good question β€” but this is handled by design.

VOSA key derivation is deterministic (stealth addresses via ERC-5564). The recipient’s VOSA private key is derived from their master private key + the ephemeralPubKey emitted in the on-chain event. There is no randomly generated key material that needs to be persisted at the point of transaction submission.

Recovery path: The SDK scans on-chain events (Transfer, Mint, etc.), extracts ephemeralPubKey from each, attempts ECDH with the user’s master key, and recovers all VOSAs that belong to them β€” including any change outputs from interrupted sessions.

So even if the client crashes immediately after submitting: the transaction either landed on-chain (events are there, SDK can recover) or it didn’t (inputs are unspent, user retries). No key material is ever lost.

1 Like