[Draft ERC]: pERC-20 — Privacy-Preserving Native ERC-20 Token Standard

Field Value
Status Draft
Type Standards Track
Category ERC
Requires EIP-712, ERC-5564

Abstract

This ERC defines pERC-20, a standard interface for native tokens with built-in amount privacy. Unlike VOSA-20 (which wraps existing ERC-20 tokens), pERC-20 is for tokens that are natively private — minted directly into hidden balances, never exposed in a public ledger.

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: pERC-20 provides Amount Privacy — balances and transfer amounts are hidden using Poseidon hash commitments, verified through on-chain Groth16 ZK proofs. The VOSA-to-VOSA transfer graph remains publicly auditable. This deliberate design choice enables regulatory compliance while protecting financial privacy.

Key Features:

  • Natively private: tokens are minted directly into Poseidon commitments
  • 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)
  • Subclass-friendly: core operations are external virtual with internal _execute* for safe override

Motivation

The Privacy Gap in ERC-20

Current ERC-20 tokens expose all financial information:

balanceOf(0x1234...) → 1,000,000 USDC   // Anyone can see this
Transfer(from, to, 50000)               // Amount is public

This creates privacy issues:

  • Salary transparency: Anyone can see compensation
  • Business exposure: Competitors see transaction volumes and treasury size
  • Personal finance: Wallet holdings are public

Why a Native Standard?

VOSA-20 adds privacy to existing tokens via wrapping. But what if you want to issue a new token that is private by default?

Approach Use Case deposit/withdraw mint/burn
VOSA-20 (wrapped) Add privacy to existing tokens (USDC, WETH) Yes No
pERC-20 (native) Issue new tokens with built-in privacy No Yes

Use cases that need pERC-20 specifically:

  • RWA tokens: Securities, bonds, real estate tokens where balance privacy is a regulatory requirement
  • Corporate tokens: Internal governance/utility tokens where holdings should not be public
  • Stablecoins: Privacy-preserving stablecoins issued by regulated entities
  • Compliance-gated tokens: Tokens where every operation requires a compliance proof

Privacy Comparison

Solution Amount Privacy VOSA Holder Identity Transfer Graph Compliance
ERC-20 :cross_mark: :cross_mark: :cross_mark: :white_check_mark: Transparent
pERC-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

pERC-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

┌─────────────────────────────────────────────────────────────────────────────────┐
│                           pERC-20 Architecture                                   │
├─────────────────────────────────────────────────────────────────────────────────┤
│                                                                                 │
│  Storage:                                                                       │
│  ┌────────────────────────────────────────────────────────────────────────┐    │
│  │  mapping(address => bytes32) balanceCommitment  // Hidden balance      │    │
│  │  mapping(address => PolicyMeta) policies        // Authorization       │    │
│  │  mapping(address => PermitMeta) permits         // One-time auth       │    │
│  │  uint256 totalSupply                            // Public              │    │
│  └────────────────────────────────────────────────────────────────────────┘    │
│                                                                                 │
│  Operations:                                                                    │
│  ┌────────────────────────────────────────────────────────────────────────┐    │
│  │  mint(amount, recipient, commitment, proof)     // Issue tokens        │    │
│  │  burn(input, amount, proof, signature)          // Destroy tokens      │    │
│  │  transfer(inputs[], outputs[], proof, sigs)     // Private transfer    │    │
│  │  consolidate(inputs[], output, proof, sigs)     // Merge VOSAs         │    │
│  │  createPolicy(spender, commitment, proof, sig)  // Recurring auth      │    │
│  │  createPermit(spender, commitment, proof, sig)  // One-time auth       │    │
│  └────────────────────────────────────────────────────────────────────────┘    │
│                                                                                 │
└─────────────────────────────────────────────────────────────────────────────────┘

On-chain state is a single mapping:

mapping(address => bytes32) balanceCommitment;
// bytes32(0)                     → never used
// Poseidon(amount, blinder, ts)  → has balance (commitment hides amount)
// SPENT_PREFIX | block.number    → spent (deletable after configurable window)

Dependencies

  • EIP-712: Typed structured data signing (all operations)
  • ERC-5564: Stealth address standard (VOSA address derivation)

ERC-20 vs pERC-20 Comparison

ERC-20 Interface pERC-20 Interface Notes
name() name() Same
symbol() symbol() Same
decimals() decimals() Same
totalSupply() totalSupply() Same
balanceOf(addr) hasBalance(addr) + balanceCommitment(addr) Amount hidden
transfer(to, amount) transfer(inputs[], outputs[], proof, ...) ZK proof required
approve(spender, amount) createPolicy(spender, commitment, proof, ...) Amount hidden
allowance(owner, spender) getPolicy(policyAddr) Amount hidden
transferFrom(from, to, amount) Policy-based transfer() Spender signs
N/A mint(amount, recipient, commitment, proof, ...) Token issuance
N/A burn(input, amount, proof, signature, ...) Token destruction
N/A consolidate(inputs[], output, proof, sigs, ...) Merge VOSAs

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;

/// @notice Timestamp validation window (±2 hours)
uint256 public constant TIMESTAMP_WINDOW = 2 hours;

/// @notice Default cleanup window (~1 month at 12s/block)
uint256 public constant DEFAULT_CLEANUP_WINDOW = 216_000;

Core Interface

Data Structures

struct PolicyMeta {
    address owner;      // Authorization creator
    address spender;    // Authorized spender
    uint256 expiry;     // 0 = never expires
    bool revoked;
}

struct PermitMeta {
    address owner;
    address spender;
    uint256 expiry;
    bool revoked;
    bool used;          // Single-use
}

struct CreatePolicyParams {
    address input;
    bytes32 inputCommitment;
    address policyAddress;
    bytes32 policyCommitment;
    uint256 policyTimestamp;
    address changeAddress;
    bytes32 changeCommitment;
    uint256 changeTimestamp;
    address spender;
    uint256 expiry;
    uint256 deadline;
}

struct CreatePermitParams {
    address input;
    bytes32 inputCommitment;
    address permitAddress;
    bytes32 permitCommitment;
    uint256 permitTimestamp;
    address changeAddress;
    bytes32 changeCommitment;
    uint256 changeTimestamp;
    address spender;
    uint256 expiry;
    uint256 deadline;
}

Metadata

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

VOSA State Management

interface IPrivateERC20VOSA {
    /// @return bytes32(0) if unused, SPENT_MARKER if spent, commitment if active
    function balanceCommitment(address vosa) external view returns (bytes32);
    function hasBalance(address vosa) external view returns (bool);
    function isEverUsed(address vosa) external view returns (bool);
    function isSpent(address vosa) external view returns (bool);
    function getSpentBlock(address vosa) external view returns (uint256);
    function batchHasBalance(address[] calldata vosas) external view returns (bool[] memory);
    function batchGetCommitment(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 IPrivateERC20Core {
    /// @notice Mint tokens — amount is public at issuance, hidden in commitment thereafter
    function mint(
        uint256 amount, address recipient, bytes32 commitment,
        uint256 outputTimestamp, bytes calldata ephemeralPubKey,
        bytes calldata proof, bytes calldata memo
    ) external returns (bool);

    /// @notice Burn tokens — amount becomes public, optional change output
    function burn(
        address input, bytes32 inputCommitment, uint256 amount,
        address changeAddress, bytes32 changeCommitment,
        uint256 changeTimestamp, bytes calldata changeEphemeralPubKey,
        bytes calldata signature, bytes calldata proof, uint256 deadline
    ) 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 Merge multiple VOSAs into one (reduce fragmentation, 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. Policies persist across multiple transfers until revoked or expired.

interface IPrivateERC20Policy {
    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 IPrivateERC20Permit {
    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 IPrivateERC20Events {
    event Mint(address indexed minter, address indexed recipient, uint256 amount,
        bytes32 commitment, bytes ephemeralPubKey, bytes memo);
    event Burn(address indexed input, uint256 amount, address changeAddress,
        bytes32 changeCommitment, bytes changeEphemeralPubKey);
    event Transfer(address[] inputs, address[] outputs, bytes32[] outputCommitments,
        bytes[] ephemeralPubKeys, bytes memo);
    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 PolicyMigrated(address indexed oldAddress, address indexed newAddress);
    event PolicyRevoked(address indexed policyAddress);
    event PermitCreated(address indexed permitAddress, address indexed owner,
        address indexed spender, uint256 expiry, bytes32 permitCommitment,
        address changeAddress, bytes32 changeCommitment, bytes[] ephemeralPubKeys, bytes memo);
    event PermitUsed(address indexed permitAddress);
    event PermitRevoked(address indexed permitAddress);
    event MinterUpdated(address indexed previousMinter, address indexed newMinter);
    event VerifierUpdated(string indexed verifierType, address newVerifier);
    event AddressCleaned(address indexed addr, uint256 spentBlock);
    event CleanupWindowUpdated(uint256 oldWindow, uint256 newWindow);
}

EIP-712 Type Definitions

All mutable parameters are included in signatures to prevent frontrunning substitution.

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

bytes32 constant BURN_TYPEHASH = keccak256(
    "Burn(address input,bytes32 inputCommitment,uint256 amount,"
    "address changeAddress,bytes32 changeCommitment,uint256 deadline)"
);

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

bytes32 constant CREATE_POLICY_TYPEHASH = keccak256(
    "CreatePolicy(address input,bytes32 inputCommitment,address policyAddress,"
    "bytes32 policyCommitment,address changeAddress,bytes32 changeCommitment,"
    "address spender,uint256 expiry,bytes32 ephemeralKeysHash,uint256 deadline)"
);

bytes32 constant CREATE_PERMIT_TYPEHASH = keccak256(
    "CreatePermit(address input,bytes32 inputCommitment,address permitAddress,"
    "bytes32 permitCommitment,address changeAddress,bytes32 changeCommitment,"
    "address spender,uint256 expiry,bytes32 ephemeralKeysHash,uint256 deadline)"
);

ZK Circuit Specifications

pERC-20 reuses the same circuits as VOSA-20 (erc20-wrapped).

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)

AmountCircuit

For mint and burn — verifies commitment matches public amount:

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

Constraints:
  mint:     absAmount == outputAmount, outputCommitment correct
  burn:     inputAmount == absAmount + changeAmount, both commitments correct
  Range:    all amounts in [0, 2^96)
  Blinder:  all blinders ≠ 0
Variant Public Signals Constraints
AmountCircuit(0,1) — mint 5 ~782
AmountCircuit(1,0) — full burn 4 ~782
AmountCircuit(1,1) — partial burn 6 ~1,240

TransferCircuit

For transfer, consolidate, createPolicy, createPermit — verifies balance conservation without revealing amounts:

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

Constraints:
  1. Each inputCommitment == Poseidon(inputAmount, inputBlinder, inputTimestamp)
  2. Each outputCommitment == Poseidon(outputAmount, outputBlinder, outputTimestamp)
  3. sum(inputAmounts) == sum(outputAmounts)
  4. All amounts in [0, 2^96)
  5. All blinders ≠ 0
  6. txHash == Poseidon(inputCommitments || outputCommitments)
Variant Public Signals Constraints
TransferCircuit(1,1) — simple send 4 ~700
TransferCircuit(1,2) — split 6 ~1,100
TransferCircuit(2,1) — merge (consolidate 2→1) 5 ~1,100
TransferCircuit(2,2) — standard transfer 7 ~1,500
TransferCircuit(5,1) — consolidate 5→1 8 ~2,900

Note: The contract allows up to MAX_INPUTS=10, but the current circuit/verifier deployment supports up to 5 inputs. TransferCircuit(10,1) and larger variants can be compiled from the same template and added to the verifier wrapper as needed.

Poseidon Parameters

Hash:   Poseidon
Width:  t = 3 (2 inputs + 1 capacity) for commitments; t varies for txHash
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
}

For burn operations, only the owner may sign (never the delegate), preventing policy/permit spenders from redeeming assets:

function _getBurnAuthorizedSigner(address input) internal view returns (address) {
    PolicyMeta memory pMeta = _policyMeta[input];
    if (pMeta.owner != address(0)) return pMeta.owner;
    PermitMeta memory tMeta = _permitMeta[input];
    if (tMeta.owner != address(0)) return tMeta.owner;
    return input;
}

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 reclaim storage gas.

interface IPrivateERC20Cleanup {
    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
}

Cleanup also removes any residual _policyMeta and _permitMeta on spent addresses.

CompatibleERC20 Variant (Optional Extension)

For backward compatibility with existing DeFi (CEX, Uniswap, etc.), an OPTIONAL CompatibleERC20 extends PrivateERC20 with:

  • Standard ERC-20 public balances (balanceOf, publicTransfer, approve, transferFrom)
  • conceal(amount): public balance → private VOSA
  • reveal(vosa, amount, recipient, proof): private VOSA → public balance
interface ICompatibleERC20 is IPrivateERC20 {
    // Standard ERC-20 (note: publicTransfer instead of transfer to avoid conflict with private transfer)
    function balanceOf(address account) external view returns (uint256);
    function publicTransfer(address to, uint256 amount) external returns (bool);
    function approve(address spender, uint256 amount) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);

    // Mode switching
    function conceal(uint256 amount, address newVosa, bytes32 newCommitment,
        uint256 timestamp, bytes calldata ephemeralPubKey,
        bytes calldata proof) external returns (bool);

    function reveal(address vosa, bytes32 inputCommitment, uint256 amount,
        address recipient, address changeAddress, bytes32 changeCommitment,
        uint256 changeTimestamp, address changeVosa,
        bytes calldata changeEphemeralPubKey, bytes calldata signature,
        bytes calldata proof, uint256 deadline) external returns (bool);

    function publicSupply() external view returns (uint256);
    function privateSupply() external view returns (uint256);
}

CompatibleERC20 adds its own EIP-712 type and events:

bytes32 constant REVEAL_TYPEHASH = keccak256(
    "Reveal(address vosa,bytes32 inputCommitment,uint256 amount,address recipient,"
    "address changeAddress,bytes32 changeCommitment,uint256 deadline)"
);

event PublicTransfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
event Revealed(address indexed vosa, address indexed recipient, uint256 amount);
event Concealed(address indexed from, address indexed vosa, uint256 amount);

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:

  • AmountVerifier: Simple mint/burn with public amount (~782–1,240 constraints)
  • TransferVerifier: Complex multi-input/output with hidden amounts (~700–5,500 constraints)

Both use an adapter pattern (IGroth16Verifier with dynamic uint256[] publicInputs) to route to the appropriate circuit variant based on input count.

Why SPENT_MARKER Instead of Nullifiers?

Aspect Nullifier (Tornado/Railgun) SPENT_MARKER (VOSA)
Transfer graph Hidden Visible
Circuit complexity Higher (Merkle membership proof) Lower
State lookup O(log n) O(1)
State growth Unbounded (nullifiers forever) Bounded (epoch cleanup)
Compliance Difficult Easy
10-year storage (10B txs) ~320 GB ~2.7 GB

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

Why Separate mint/burn (not deposit/withdraw)?

pERC-20 is for native tokens — there is no underlying ERC-20 to wrap/unwrap. Supply management uses mint (authorized minter creates new tokens) and burn (holder destroys tokens). This is analogous to ERC20._mint and ERC20._burn in OpenZeppelin, but with privacy.

Why Virtual Functions for Subclassing?

All six core operations (mint, burn, transfer, consolidate, createPolicy, createPermit) are external virtual, allowing subclass overrides. mint, burn, and transfer additionally expose internal _executeMint/_executeBurn/_executeTransfer that contain the core logic, enabling subclasses to call them directly without external self-calls (which would break nonReentrant and waste gas). The same _execute* pattern can be applied to consolidate, createPolicy, and createPermit if needed.

Performance

Measured on Apple M2, snarkjs WASM prover. Gas measured on XLayer Testnet with mock verifiers (add ~200K for real Groth16 verification on L2).

Gas (PrivateERC20, mock verifiers)

Operation Gas Notes
mint 78,169 Minter issues private token
burn (with change) 105,316 Owner destroys, remainder to change VOSA
transfer (1→2) 145,237 1 input, 2 outputs
consolidate (2→1) 137,673 Merge 2 VOSAs

Gas (CompatibleERC20, public mode)

Operation Gas vs Standard ERC-20
mintPublic 39,709 -20%
publicTransfer 56,385 +8%
approve 46,046 ~0%
conceal (public→private) 78,962 N/A
reveal (private→public) 86,225 N/A

Proof Generation Time

Operation snarkjs WASM rapidsnark (C++)
mint (amount_0_1) ~90 ms ~65 ms
burn with change (amount_1_1) ~130 ms ~85 ms
transfer 1→2 (transfer_1_2) ~125 ms ~70 ms
transfer 2→2 (transfer_2_2) ~150 ms ~90 ms
consolidate 5→1 (transfer_5_1) ~210 ms ~105 ms

Backwards Compatibility

  • pERC-20 does NOT implement the ERC-20 interface (balanceOf, transfer(address,uint256), etc.). It is a separate standard for natively private tokens.
  • For tokens that need both public and private modes, use CompatibleERC20 which implements the full ERC-20 interface alongside private operations.
  • 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.

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 all mutable parameters including policyChangeIndices and ephemeralPubKeys
  • ephemeralKeysHash uses per-key keccak256 then packs as bytes32[] to avoid abi.encodePacked collision on variable-length bytes[]
  • 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 balanceCommitment[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)

Burn Authorization

For policy/permit-delegated VOSAs, burn always requires the owner’s signature (not the delegate’s). This prevents delegates from redeeming/destroying assets they were only authorized to transfer.

Metadata Cleanup

Policy and permit metadata are cleaned up when a VOSA is spent:

  • burn: immediately deletes both _policyMeta and _permitMeta on the input address
  • createPolicy / createPermit: immediately deletes both on the consumed input address
  • transfer: _policyMeta is deleted via _handlePolicyMigration (migrated to change output or simply deleted); _permitMeta is marked used = true (actual deletion happens during epoch cleanup)
  • epoch cleanup: deletes all residual _policyMeta and _permitMeta on spent addresses past the cleanup window

This prevents stale authorization data from persisting and potentially being inherited if the address is reused after cleanup.

Address Security

  • Address collision probability: ~2^-80 (negligible for 20-byte addresses)
  • Replay protection: DOMAIN_SEPARATOR includes chainId + contract address; each VOSA spent only once

Timestamp Validation

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

Compliance (Subclass)

Subclasses that require compliance gating can override external entry points and enforce additional checks (e.g. ZK compliance proofs) before calling the internal _execute* functions.

Reference Implementation

Repository: [GitHub link]

erc20-native/
├── contracts/
│   ├── src/
│   │   ├── PrivateERC20.sol          # Core pERC-20 (EIP-712, Pausable, ReentrancyGuard, Ownable)
│   │   ├── CompatibleERC20.sol       # Dual-mode: ERC-20 + pERC-20
│   │   ├── interfaces/
│   │   │   ├── IPrivateERC20.sol     # Full interface (364 lines)
│   │   │   ├── ICompatibleERC20.sol
│   │   │   ├── IGroth16Verifier.sol
│   │   │   └── IPoseidon.sol
│   │   └── mocks/
│   │       ├── MockGroth16Verifier.sol
│   │       └── MockPoseidon.sol
│   └── test/
│       ├── PrivateERC20.test.ts      # 33 tests
│       └── CompatibleERC20.test.ts   # 30 tests (63 total)
├── circuits/                          # Reuses erc20-wrapped circuits
│   ├── configs/
│   │   ├── amount_0_1.circom         # Mint (0→1)
│   │   ├── amount_1_0.circom         # Full burn (1→0)
│   │   ├── amount_1_1.circom         # Partial burn (1→1)
│   │   ├── transfer_1_1.circom       # Simple send (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)
│   └── lib/
│       ├── hash_commitment.circom     # Commitment + range proof
│       ├── chunked_poseidon.circom    # Variable-input Poseidon
│       └── sum.circom                 # Balance conservation
└── sdk/                               # TypeScript SDK for proof generation

Test Cases

Required coverage:

  1. Mint: Correct commitment, ZK verification, minter authorization, zero amount rejection
  2. Burn: Full and partial burn with change, owner-only signature for delegated VOSAs, metadata cleanup
  3. Transfer: Multi-input/output (1→1, 1→2, 2→2, 5→1), balance conservation, duplicate/overlap checks, policyChangeIndices in signature
  4. Consolidate: Multiple VOSAs to one, same-owner enforcement, no policy/permit addresses
  5. Policy: Create, use (spender), revoke (owner), expire, auto-migrate to change, metadata cleanup on spend
  6. Permit: Create, use (one-time), revoke, expire, metadata cleanup on spend
  7. Cleanup: Epoch window, batch cleanup, metadata cleanup, cannot clean active VOSAs
  8. Security: Replay prevention, double-spend, front-running (ephemeralPubKeys substitution), overflow, reentrancy
  9. CompatibleERC20: Public transfer, conceal, reveal, supply tracking consistency
  10. Subclassing: Override external entry points, call _execute* internally, verify compliance hooks work correctly

Copyright

Copyright and related rights waived via CC0.