| 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 | ||||
| VOSA-20 | ||||
| Tornado/Railgun |
Design Philosophy
VOSA-20 targets Balance Privacy, not Anonymous Mixing:
Corporate financial privacy (hide amounts from competitors)
Personal balance privacy (hide wallet holdings)
Compliant private transactions (satisfy KYC/AML)
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 standardtransferFrom;withdraw()uses standardtransfer. 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_callfor 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_SEPARATORincludeschainId+ 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:
- Deposit: Correct commitment, ZK verification, fee-on-transfer rejection
- Transfer: Multi-input/output (1β2, 2β2, 5β1), balance conservation, duplicate/overlap checks
- Withdraw: Full and partial withdrawal with change
- Consolidate: Multiple VOSAs to one, same-owner enforcement
- Policy: Create, use (spender), revoke (owner), expire, auto-migrate to change
- Permit: Create, use (one-time), revoke, expire
- Cleanup: Epoch window, batch cleanup, cannot clean active VOSAs
- Security: Replay prevention, double-spend, front-running, overflow
Copyright
Copyright and related rights waived via CC0.