| 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 virtualwith 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 | ||||
| pERC-20 | ||||
| Tornado/Railgun |
Design Philosophy
pERC-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
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 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 VOSAreveal(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
policyChangeIndicesandephemeralPubKeys ephemeralKeysHashuses per-key keccak256 then packs asbytes32[]to avoidabi.encodePackedcollision on variable-lengthbytes[]- 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
_policyMetaand_permitMetaon the input address - createPolicy / createPermit: immediately deletes both on the consumed input address
- transfer:
_policyMetais deleted via_handlePolicyMigration(migrated to change output or simply deleted);_permitMetais markedused = true(actual deletion happens during epoch cleanup) - epoch cleanup: deletes all residual
_policyMetaand_permitMetaon 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_SEPARATORincludeschainId+ 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:
- Mint: Correct commitment, ZK verification, minter authorization, zero amount rejection
- Burn: Full and partial burn with change, owner-only signature for delegated VOSAs, metadata cleanup
- Transfer: Multi-input/output (1→1, 1→2, 2→2, 5→1), balance conservation, duplicate/overlap checks, policyChangeIndices in signature
- Consolidate: Multiple VOSAs to one, same-owner enforcement, no policy/permit addresses
- Policy: Create, use (spender), revoke (owner), expire, auto-migrate to change, metadata cleanup on spend
- Permit: Create, use (one-time), revoke, expire, metadata cleanup on spend
- Cleanup: Epoch window, batch cleanup, metadata cleanup, cannot clean active VOSAs
- Security: Replay prevention, double-spend, front-running (ephemeralPubKeys substitution), overflow, reentrancy
- CompatibleERC20: Public transfer, conceal, reveal, supply tracking consistency
- Subclassing: Override external entry points, call
_execute*internally, verify compliance hooks work correctly
Copyright
Copyright and related rights waived via CC0.