[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.

3 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.

2 Likes

Very cool! Some comments come to my mind.

  1. The fact that receiver anonymity is achieved using the already existing ERC-5564 is very elegant!
  2. 2^96 is not enough to hold 18-digit numbers, like e.g ETH and DAI would use. We can transact in 10^-9 units though like we already do on the Beacon Chain. Is that something you plan to do?
  3. Is it necessary to have the temporary SPENT state? Why one month? What would break if we skipped that and set it to UNUSED directly? I don’t think other “confidential balance”-type systems use such a mechanism, but I could have missed something. Regarding gas savings, Ethereum is quite stingy with gas refunds in order to kill gas tokens, and so I would expect async cleaning to be generally unprofitable. OTOH, if you clear the slot synchronously in a transaction that has to spend a lot on zk verification, it subsidizes your proof a little bit (to be measured)
  4. How does one pay gas? If you use Ether from your main account, you practically lose the anonymity benefits

Great questions — let me address each:

2^96 and 18-decimal tokens

2^96 ≈ 7.9 × 10^28. For an 18-decimal token, this represents ~79 billion whole tokens — more than sufficient for ETH (~120M supply), DAI, USDC, or any realistic token. We chose 96 bits specifically because it covers all practical ERC-20 supplies with 18 decimals while keeping the range proof circuit small (~350 constraints for Poseidon commitment + range check). No precision reduction needed. (But you can also change it)

Why the temporary SPENT state?

Two reasons:

  1. Client synchronization window. If we set UNUSED immediately, a spent address could be reoccupied by a new deposit in the very next block. The old owner’s client, scanning events to track their VOSAs, would see the address reappear with a different commitment and potentially misinterpret it. The SPENT window (default ~1 month) gives all clients time to observe the spend event and update their local state.

  2. Auditability. The SPENT marker encodes block.number, providing on-chain evidence of when an address was consumed. Useful for compliance and dispute resolution.

You’re right that Ethereum gas refunds are capped (EIP-3529), so async cleanup doesn’t yield huge savings. The primary benefit is preventing unbounded state growth — without cleanup, every VOSA ever used persists forever. With cleanup, long-term storage is bounded to active balances + recent spends. The gas refund is a small bonus, not the motivation.

As for setting UNUSED synchronously in the spending transaction — that would indeed save a future cleanup call, but then isEverUsed() immediately returns false, allowing the same address to be reused. We prefer the explicit lifecycle for clarity.

Gas payment and anonymity

This is the classic “who pays gas” problem in any on-chain privacy system, and it’s not unique to VOSA-20. You’re correct: if Alice pays gas from her main EOA, the transaction links her identity to the VOSA operation.

Practical mitigations:

  • Relayers / meta-transactions: A third party submits the transaction on behalf of the user. The ZK proof and ECDSA signature are still valid regardless of msg.sender — our contracts don’t check msg.sender for transfers, only the VOSA owner’s signature.

  • ERC-4337 (Account Abstraction): Paymasters can sponsor gas, decoupling the gas payer from the VOSA owner.

  • Dedicated gas wallet: Use a separate, unlinkable EOA funded through a DEX or bridge.

This is an application-layer / wallet UX concern rather than a protocol-level one. The VOSA-20 contract interface is already relayer-compatible — transfer(), withdraw(), etc. verify the VOSA owner’s EIP-712 signature, not msg.sender.

18-decimal tokens

This was my mistake: I got confused about the value of 2^96/10^18 and thought it was a lot less than it is. Your design is perfectly sound.

SPENT state

Personally, I am not convinced by either of these reasons. Clients and auditors should learn about transfers primarily via EVM events (or a similar mechanism like shadow events) and not indirectly by checking the state. ERC-5564 already uses events so clients will have to process them regardless. Also observe that ERC-20 does not need a spent marker either.

That said this is just my opinion. I would find the protocol simpler and more elegant without spend markers, but feel free to go to testnet with spent markers and see how it fares in practice. That is the ultimate test.

Gas payments

This is all true. As it stands however, relayers or ERC-4337 bundlers would not be incentivized to include VOSA-20 transfers. Where would the paymaster get money? I think the protocol should have built-in support for relayer fees, like Privacy Pools does, otherwise if at all possible it will get very ugly to implement it out-of-protocol. And regarding dedicated gas wallets, if there is already a mechanism to fund an unlinkable EOA, wouldn’t people simply route payments through that mechanism?

SPENT state

Fair point. You’re right that clients must process events regardless (ERC-5564 scanning), so the SPENT marker isn’t strictly necessary for client state tracking.

The practical value is narrower than I described: it’s mainly a guard against accidental address reuse. Without it, isEverUsed() can’t distinguish “never used” from “used and cleaned up”, and a buggy or out-of-sync client could deposit to a recently-spent address. The SPENT window makes that a hard error instead of a silent collision.

But you’re right that this is a belt-and-suspenders measure, not a fundamental requirement. The protocol would work without it — simpler, as you said. We’ll keep it for the initial deployment (it’s a conservative choice) and may revisit based on testnet experience.

Gas payments

On reflection, built-in relayer fee support is not needed for VOSA-20 — and the reason goes back to our core design choice.

VOSA-20 provides Amount Privacy, not Full Anonymity. The VOSA-to-VOSA transfer graph is deliberately visible on-chain. This means:

  • Deposit: Already links your public address to a VOSA (public Deposit event)

  • Transfer: Graph is visible (inputs → outputs), only amounts are hidden

  • Withdraw: Already links a VOSA to the recipient address

Since fund flow is visible by design, paying gas from a linked EOA does not leak additional information that isn’t already observable. The observer can see “address X initiated a VOSA transfer” — but the transfer graph was already public.

This is where VOSA-20 differs fundamentally from Tornado Cash and Privacy Pools. Those systems hide the deposit→withdrawal link, so gas payment breaks their core privacy guarantee. VOSA-20 doesn’t make that guarantee — we chose compliance-friendly auditability over fund-flow privacy.

If a future variant of VOSA targets full fund-flow privacy (e.g. with nullifiers instead of SPENT markers), then built-in relayer fees would become essential. But for the current design, it’s not a gap — it’s consistent with the privacy model.

Actually wrt business confidentiality also the information with whom you transact matters.

Nevertheless I love this VOSA design - even more, that in some languages vosa means wasp :honeybee: so it can be nicely presented :slight_smile:

Loving the SPENT markers with TTL and cleanup, so the state shall not explode.

Regarding the ERC20 - one of the issues of the ERC20 design is the missing memo (or transfer reference) field ( usable for reconciliation - see the ERC-7699: ERC-20 Transfer Reference Extension for some details and potential ERC20 extension. )
How would you do the reconcilliation with VOSA-20 without reference field?

Regarding the VOSA ERC and its interface definition:

IVOSA20Metadata can be optional - for the cases one wants to set VOSA contract with underlying ERC20.

But there will be cases that one will want to embed the VOSA directly as the extended features into fresh ERC20 ( = no underlying token) .

I.e. I suggest only the core minimum to be required in the standard and defined as the ERC20 extension.

Edit: I see there is the non-wrapped version here: [Draft ERC]: pERC-20 — Privacy-Preserving Native ERC-20 Token Standard .

Thanks for the thoughtful feedback!

On transaction counterparty privacy: You’re right — “who you transact with” is sensitive too. VOSA deliberately makes the transfer graph visible as a compliance tradeoff, but we’re exploring ways to break the link at the deposit boundary (between real-world identity and in-pool VOSAs) while keeping the in-pool graph auditable. It’s an active design area for us.

On reconciliation without a reference field: VOSA-20’s transfer() and deposit() both include a bytes calldata memo parameter — this is an encrypted field that can carry arbitrary data (transfer reference, invoice ID, payment metadata, etc.). Only the intended recipient can decrypt it (via the ephemeral key + ECDH). So reconciliation works through the memo, and the reference itself is private — third parties can’t see it. This actually provides stronger reconciliation privacy than ERC-7699’s plaintext reference field.

On interface design: Good suggestion. We agree that the core standard should define the minimum required interface. The non-wrapped version 1 is exactly what you described — embedding VOSA features directly into a fresh token with no underlying. We’re considering whether VOSA-20 and pERC-20 should share a common core interface with optional extensions (metadata, underlying token, dual-mode, etc.).

On the name: We didn’t know about the wasp connection — might have to use that for branding!

1 Like

Thanks for clarification as the key description does not include memo among privacy fields:

1 Like