Draft ERC: VOSA-20 — Privacy-Preserving Wrapped ERC-20 Token Standard
Ethereum Magicians
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 Transparent VOSA-20 (by design) Auditable Tornado/Railgun Difficult 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 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: 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 . 1 post - 1 participant Read full topic