Autor: _pERC20Labs · Type: Standards Track (ERC) · Created: 2026-06-03
Simple Summary
A standard interface for privacy-native fungible tokens.
Abstract
The following standard allows for the implementation of a standard API for privacy-native fungible tokens within smart contracts on the EVM. This standard provides basic functionality to transfer, mint, and burn tokens whose balances and transfer amounts are private by default, while keeping a publicly verifiable totalSupply. Tokens exist as encrypted UTXO notes (Groth16 proofs, Orchard-style model) from issuance onward. Each pERC20 asset MUST bind to a compliance frozen root maintained by the asset contract, so that blacklisted notes cannot be spent.
This standard is not binary-compatible with ERC-20: there is no public balanceOf, and no approve / allowance. Instead it defines an equivalent privacy interface, IPERC20.
This proposal is complementary to EIP-8182: EIP-8182 targets a private transfer layer for ETH and compatible ERC-20 assets, while pERC20 defines a privacy-native token standard interface at the asset level.
Motivation
Ethereum is increasingly focused on native privacy at the protocol layer. Several EIPs already propose complementary building blocks — including EIP-7805 (FOCIL), EIP-8141 (Frame Transactions), EIP-8250 (Keyed Nonces), and EIP-8272 (Recent Roots). If these proposals are adopted together, Ethereum could support trustless, censorship-resistant private transactions at the base layer — and the ecosystem will need a privacy-native fungible token standard with the same standardizing role that ERC-20 plays for public tokens.
This document proposes pERC20 to fill that gap: a standard interface for privacy-native fungible assets on the EVM, built on an Orchard-style ZK-UTXO model (Groth16 proofs, adapted from the Zcash Orchard protocol). By contrast, ERC-20 exposes balances and transfers entirely on-chain, revealing holder distribution, counterparties, and amounts. pERC20 adopts a note-based UTXO model with the following goals:
- Privacy by default: assets are private from issuance; no public-to-shielded conversion step is required.
- UTXO-level privacy: no account balance is subject to pattern analysis; anonymity sets are formed from homogeneous notes.
- Verifiable honesty:
totalSupplyis public, preventing invisible inflation. - Built-in compliance: the protocol layer can freeze specific notes to satisfy regulatory actionability requirements.
- EVM deployability: a standard interface enables unified integration by wallets, indexers, and services.
Specification
The key words MUST, MUST NOT, SHOULD, and MAY in this document are to be interpreted as described in RFC 2119.
Terminology
| Term | Meaning |
|---|---|
| note | An encrypted UTXO carrying a value (token amount) and recipient information |
commitment / cmx |
A note commitment (Merkle tree leaf, x-coordinate) |
nullifier / nf |
A one-time public marker revealed when spending a note; prevents double-spending and is unlinkable to identity |
| anchor | A historical Merkle root referenced by a spend |
perc1 address |
A privacy address for receiving pERC20 (Orchard key derivation), defined within this specification (no separate companion EIP) |
cmxFrozenRoot / rt_frozen |
Root of a compliance blacklist Sparse Merkle Tree; a public circuit input |
| binding signature | A Schnorr signature proving value conservation for an operation |
| spend-auth signature | A Schnorr signature proving authorization to spend a note |
| bundle | A single operation comprising one or more actions (each action = optional spend + one output) |
Data Structures
The following specifications use syntax from Solidity 0.8.20 (or above).
PrivacyCall
Product-layer call payload for IPERC20 methods.
struct PrivacyCall {
bytes actions; // = abi.encode(BundleAction[])
uint256[3] bindingSig; // [Rx, Ry, s] Baby JubJub Schnorr binding signature
}
BundleAction
Implementation-layer action for IEndpointCore.
struct BundleAction {
bytes32 cmx; // output note commitment
bytes encCiphertext; // recipient ciphertext; length MUST match the note encryption format (see below)
bytes outCiphertext; // sender self-recovery ciphertext; length MUST match the OVK encryption format (see below)
bytes32 epk; // ephemeral public key
bytes32 nfOld; // nullifier of the consumed input note
bytes32 anchor; // historical root used by the consumed input note
bytes proof; // Groth16 proof = abi.encode(pA, pB, pC)
uint256[8] pubFields; // public inputs; [7] MUST == cmxFrozenRoot
uint256[3] spendAuthSig; // spend authorization signature for the consumed input note
}
Note ciphertext format: This standard does not embed a specific note-plaintext layout into its byte-level invariants, but implementations MUST fix one and document it. The reference implementation uses the Orchard-style field sizes defined in Zcash Protocol Specification §7.5 Action Description Encoding and Consensus:
encCiphertext= 580 bytes: ChaCha20-Poly1305 ciphertext of the note plaintext under a key derived fromepkand the recipient’s incoming viewing key (IVK) (564-byte plaintext + 16-byte Poly1305 tag; plaintext fields include diversifier/transmission key encoding,value,rseed, and a 512-byte memo).outCiphertext= 80 bytes: ChaCha20-Poly1305 ciphertext of the byte string formed from the transmission key and ephemeral secret key, encrypted under the sender’s outgoing viewing key (OVK), used for sent-note recovery (64-byte plaintext + 16-byte Poly1305 tag).
Implementations that change the note-encryption layout (different cipher, different plaintext schema, different memo size) MUST publish a separate variant of this EIP rather than silently changing the lengths.
pubFields ordering MUST be: [anchor, cv_net_x, cv_net_y, nf_old, rk_x, rk_y, cmx, rt_frozen].
This layout is derived from the Orchard Action primary inputs in Zcash Protocol Specification §4.18.4 Action Statement (Orchard) (anchor, net value commitment cv_net, nf_old, randomized key rk, output commitment cmx). pERC20 makes two deliberate deviations for compliance and a unified verification path:
rt_frozenreplacesenable_spend/enable_output: slot[7]binds each action to the asset’s compliance blacklist SMT root (IPERC20.cmxFrozenRoot()); the circuit proves non-membership of the spent note commitment under that root.- Single action shape for mint / transfer / burn: dummy-input mints and real spends share the same public-field layout; enable flags are not exposed as separate public inputs (handled inside the circuit instead).
On-chain implementations MUST compress these eight fields into a single Groth16 public signal pub_hash via ActionPubHash (Poseidon sponge), and this compression function MUST match the circuit’s PubHashAction().
Each element of pubFields MUST be a canonical field element (strictly less than the scalar field modulus Fr of the SNARK curve used by the implementation’s verifier); otherwise the call MUST revert (PubFieldOutOfRange). Without this constraint, ActionPubHash reduces inputs modulo the field before hashing while the SNARK verifier only checks the final pub_hash; an attacker could submit nf + Fr to produce the same pub_hash and proof yet map to a different nullifier-set key, enabling double-spend of the same note.
IPERC20
Implementations MUST return true on success and MUST revert on failure; the bool return is kept for ERC-20 caller convention compatibility.
name
Returns the name of the token — e.g. "MyPrivateToken".
function name() external view returns (string memory)
symbol
Returns the symbol of the token. E.g. "pHIX".
function symbol() external view returns (string memory)
decimals
Returns the number of decimals the token uses — e.g. 8, means to divide the token amount by 100000000 to get its user representation.
function decimals() external view returns (uint8)
totalSupply
Returns the total token supply (cumulative mint minus cumulative burn). This value is public and MUST be verifiable on-chain.
function totalSupply() external view returns (uint256)
issuer
Returns the address authorized to mint under this standard.
function issuer() external view returns (address)
cmxFrozenRoot
Returns the current compliance frozen root (IPERC20 standard method).
function cmxFrozenRoot() external view returns (uint256)
setFrozenRoot
Updates the compliance frozen root after rebuilding the off-chain blacklist SMT. MUST be restricted to admin.
function setFrozenRoot(uint256 newRoot) external
transfer
Privately transfers a set of notes to recipients (note→note) with value conservation.
The implementation MUST execute the underlying bundle logic with valueBalance == 0 (via an internal execution path such as _executeBundle).
The implementation MUST return true on success; failure MUST be expressed via revert.
The implementation MUST NOT emit ERC-20’s Transfer(from, to, value) event. Per-note observability is provided by NoteAdded / NoteConfirmed (see IEndpointCore).
Sender, recipient identities, and transfer amounts MUST remain private.
Value-changing operations MUST be exposed only through controlled mint / burn / transfer; the core execution path MUST NOT be publicly callable, so callers cannot choose valueBalance direction to bypass supply accounting (see Supply Invariant below).
function transfer(PrivacyCall calldata call) external returns (bool success)
mint
Creates new notes with face value amount; totalSupply increases by amount.
The implementation MUST verify amount < ℓ (SUBGROUP_ORDER) so that the binding scalar amount mod ℓ equals the declared amount (AmountTooLarge).
The implementation MUST verify that the low 255 bits of valueBalance equal amount (amount binding).
The implementation MUST execute mint actions through the same anchor / nullifier / spend-auth verification path as transfer and burn (no output-only branch on-chain; no nfOld == 0 sentinel). The circuit MUST constrain the consumed input note of a mint action to carry v = 0 so that the action exhibits net-inflow semantics; this is a circuit-level invariant and is not directly checkable on-chain.
Authorization: mint MUST be restricted to the issuer (onlyIssuer).
Implementations MAY relax mint authorization in later versions (block windows, caps, allowlists, PoW, staking), but MUST keep totalSupply publicly accumulated.
The recipient identity MUST be private; amount is public.
function mint(uint256 amount, PrivacyCall calldata call) external
burn
Spends notes to destroy value; totalSupply decreases by amount. No external asset is released.
The implementation MUST verify amount < 2^255 (sign bit unset) and amount < ℓ (SUBGROUP_ORDER, AmountTooLarge).
The implementation MUST execute with valueBalance == amount (bit255 = 0).
The implementation MUST verify totalSupply >= amount; otherwise revert.
Authorization: any holder MAY burn their own notes (requires corresponding spend authorization).
The burner identity MUST be private; amount is public.
function burn(uint256 amount, PrivacyCall calldata call) external
Events
Mint
MUST trigger when new tokens are minted.
event Mint(address indexed issuer, uint256 amount)
Burn
MUST trigger when tokens are burned.
event Burn(uint256 amount)
FrozenRootUpdated
MUST trigger when the compliance frozen root changes.
event FrozenRootUpdated(uint256 oldRoot, uint256 newRoot)
Perc20Created
MUST be emitted once when a new pERC20 asset contract is deployed (typically in the asset constructor).
A factory deployment pattern is RECOMMENDED but not REQUIRED. Standalone deployments are conformant as long as the asset contract emits Perc20Created (or equivalent constructor-time metadata). A factory contract is not part of the normative standard; reference implementations MAY provide one (e.g. PERC20Factory) for discoverability and shared verifier wiring.
event Perc20Created(
address indexed pool,
address indexed issuer,
string name,
string symbol,
uint8 decimals
)
Indexers and wallets SHOULD listen for this event to register new assets. When a factory is used, the event MUST still originate from the deployed asset contract (not only from the factory wrapper), so standalone and factory-backed deployments produce the same observable metadata.
NOTE: This standard intentionally does not define ERC-20’s Transfer or Approval events. From/to are always hidden notes and transfer amounts are private; emitting Transfer would be misleading. Supply changes are observable via Mint / Burn; per-note details are carried by NoteAdded / NoteConfirmed.
Compliance Frozen Root
Compliance frozen root is part of IPERC20 (not a separate compliance module):
function cmxFrozenRoot() external view returns (uint256)
function setFrozenRoot(uint256 newRoot) external // onlyAdmin
event FrozenRootUpdated(uint256 oldRoot, uint256 newRoot)
- Each action’s
pubFields[7]MUST equal the currentcmxFrozenRoot(); otherwise revert (BadFrozenRoot). - The circuit MUST prove that the spent note’s commitment is not a member of the blacklist SMT rooted at
cmxFrozenRoot(non-membership proof). - The full blacklist SMT structure is maintained off-chain; only the root is stored on-chain.
setFrozenRootMUST be restricted toadmin(issuer or a dedicated compliance officer, depending on deployment andadminconfiguration) and SHOULD use multisig / timelock. Each change MUST emitFrozenRootUpdatedfor auditability.- To freeze a note: off-chain, insert its
cmxinto the blacklist SMT, compute the new root, callsetFrozenRoot(newRoot). To unfreeze, remove and callsetFrozenRootagain. - Initial
cmxFrozenRoot == 0denotes an empty blacklist (default allow).
IEndpointCore
The underlying note state machine MUST expose:
interface IEndpointCore {
function cmxRoot() external view returns (bytes32);
function isValidAnchor(bytes32 root) external view returns (bool);
event NoteAdded(
bytes32 indexed cmx,
bytes encCiphertext,
bytes outCiphertext,
bytes32 epk,
bytes32 nfOld,
bytes32 cvNetX
);
event NoteConfirmed(bytes32 indexed cmx, bytes32 newRoot, uint256 position);
event BundleExecuted(uint256 valueBalance, uint256 amount, bytes32 recipientMeta);
}
cmxRoot(): latest commitment-tree root. Wallets and indexers MAY rebuild the tree from events and compare against this value.isValidAnchor(root): whetherrootwas ever emitted by this contract’s commitment tree. Wallets SHOULD call this before submitting a transaction to validate the proof anchor.
Implementations MUST:
- Prevent double-spending via an internal nullifier set; the same
nfMUST NOT be spent twice. Nullifier membership is not required as a public view function onIEndpointCore. - Maintain an append-only Merkle commitment tree with historical roots queryable via
isValidAnchor. - Verify value conservation via binding signatures and spend authorization via spend-auth signatures; binding signature verification MUST complete before any state mutation (tree insert, nullifier mark, event emission).
- Reject duplicate
cmx(DuplicateCommitment) and zero commitmentscmx == 0(ZeroCommitment). - Verify that binding / spend-auth signature points
Rlie on the SNARK-curve-paired signature curve (the reference implementation uses Baby JubJub) with canonical field coordinates (< Fr). - Verify each action’s
pubFields[7] == IPERC20.cmxFrozenRoot()(compliance binding). - Reject empty
BundleAction[]arrays and MUST cap the number of actions per call (maxActions). The cap MUST be a finite, configurable positive integer; a reasonable upper bound is in the range 10–50 to keep proof verification gas predictable.
Implementations SHOULD emit events for all privileged configuration changes (verifier rotation, admin transfer, maxActions). Admin transfer SHOULD use a two-step flow (transferAdmin + acceptAdmin) with zero-address prohibition.
Supply Invariant
Asset contracts with totalSupply accounting (e.g. PERC20) MUST expose value changes only through controlled mint / burn / transfer. The core execution path (e.g. _executeBundle) MUST NOT be publicly callable; there is no public bundle() entry point.
NoteAdded MUST carry outCiphertext (80-byte sender self-recovery ciphertext) and cvNetX (= pubFields[1]) so wallets holding an outgoing viewing key (OVK) can scan their sent notes from logs without parsing calldata.
Value Balance Encoding
valueBalance (uint256) sign-bit encoding MUST follow:
| Operation | Encoding | Binding scalar |
|---|---|---|
| transfer | 0 |
0 (conserved) |
| burn | bit255 = 0, low bits = v | v (+v outflow) |
| mint | bit255 = 1, low bits = v | ℓ − v (−v inflow) |
Rationale
Note model and ERC-20 semantics
- UTXO notes over account balances: avoids account activity pattern leakage; homogeneous notes provide stronger anonymity sets.
- ERC-20 semantics without binary compatibility: lowers ecosystem cognitive cost while honestly reflecting the absence of public balances; binary ERC-20 compatibility would force balance exposure, contradicting the design goal.
- Public
totalSupply: minimal transparency trade-off for verifiable “no invisible inflation” without expensive range proofs. - Mint uses a dummy input (
v=0) while sharing the same verification path as burn/transfer: keeps one action-validation flow while preserving mint’s net-inflow semantics; circuit increment converges on a single additional public inputrt_frozen.
Orchard over Sapling
Orchard is chosen because its action circuit flexibly supports an arbitrary number of inputs and outputs within a single proof. pERC20 bundles may combine multiple spends and outputs in one transaction; Sapling’s fixed 1-in / 2-out action shape would force artificial splits, higher relayer cost, and weaker UX for multi-note transfers.
Groth16 over Halo2 / PLONK
Groth16 verification cost on EVM is lower than typical Halo2 or PLONK verifiers at comparable security levels. Pairing-based Groth16 checkers are well understood on-chain; VK size and verifier bytecode remain practical for per-asset deployment while keeping per-action gas predictable.
Compliance root (cmxFrozenRoot) in the circuit
The compliance blacklist is a set of cmx (note commitments), rooted at cmxFrozenRoot. When a user spends, the on-chain action exposes nfOld (nullifier), not the spent note’s cmx. There is no direct, privacy-preserving mapping from nf to cmx on-chain, so the contract cannot cheaply check “is this nullifier tied to a frozen commitment?” without breaking privacy.
The constraint is therefore enforced inside the circuit: the prover demonstrates that the consumed note’s commitment is not a member of the blacklist SMT rooted at pubFields[7] == cmxFrozenRoot(). The chain only stores and binds the SMT root; membership logic stays in ZK. This is an explicit trade-off: compliance is hard-guaranteed for identified notes, but requires trusting the circuit and the admin who updates cmxFrozenRoot.
Ecosystem Integration (Non-Normative)
This section is informational and introduces no additional protocol requirements.
pERC20 is intended to be complementary to broader privacy-transfer infrastructure. One plausible ecosystem path is:
- Existing public assets (including DeFi-relevant assets) gain a private transfer path through infrastructure such as EIP-8182.
- Privacy-native assets and notes become first-class primitives for new application design.
- New protocol categories are built directly on privacy-native state (for example, privacy-preserving exchange, lending, or structured settlement flows), instead of treating privacy as a peripheral wrapper.
This proposal does not standardize bridge construction, relayer economics, private mempool/network assumptions, or private oracle designs. Its scope is limited to token-interface semantics and invariants for privacy-native fungible assets.
Backwards Compatibility
pERC20 does not implement ERC-20’s balanceOf, approve, allowance, or transferFrom(from, to, amount), and therefore cannot be used directly by existing ERC-20 tooling.
| ERC-20 | pERC20 | Notes |
|---|---|---|
name / symbol / decimals |
same | public metadata |
totalSupply |
totalSupply |
public |
balanceOf(addr) |
none | balances private; holders scan via IVK/FVK |
transfer(to, amount) → bool |
transfer(PrivacyCall) → bool |
parties and amount private; return convention preserved |
transferFrom / approve / allowance |
none | no public balance to approve; delegation via viewing keys |
mint (extension) |
mint(amount, PrivacyCall) |
issuer-only |
burn (extension) |
burn(amount, PrivacyCall) |
any holder |
Transfer / Approval events |
Transfer omitted; per-note via NoteAdded / NoteConfirmed; supply via Mint / Burn |
identities not linkable |
Interoperability with public DeFi MAY be achieved via optional bridgeOut (pERC20 → public ERC-20 twin), where privacy terminates at the exit (not required by this standard).
Reference Implementation
Reference implementation available in the pERC20_ repository:
contracts/ptoken/PERC20.sol— normative asset contract (IPERC20reference)
PERC20 inherits OrchardVerifier (IEndpointCore) and depends on the Groth16 verifier, Merkle commitment tree, and crypto libraries from the complete reference implementation.
Security Considerations
- Double-spend protection: relies on the nullifier set and correct
nfderivation in the circuit. Once a nullifier is marked spent, a note MUST NOT be spendable again.pubFieldsfield-range checks (< Fr) are a prerequisite for double-spend protection; otherwisenf + Frcan reuse the same proof while bypassing the nullifier set (see Data Structures above). - Supply invariant: asset contracts with
totalSupplyaccounting MUST expose value changes only throughmint/burn/transfer; the core execution path MUST NOT be publicly callable, otherwise anyone could inject unaccounted value without increasingtotalSupply. - Value conservation: relies on binding signatures and a NUMS generator (
log_{G_RANDOM}(G_VALUE)unknown). Consistency betweenamountandvalueBalanceis enforced by comparing low 255 bits. Signature pointsRMUST be curve- and field-validated. - Replay protection: sighash domain separation binds
chainId, contract address, and allnf/cmxvalues, preventing cross-chain, cross-contract, and cross-bundle replay. - Anchor validity:
isValidAnchoruses a permanent set so old proof anchors remain valid, but MUST prevent forgery of roots that were never produced. - Compliance authority risk:
admin(issuer / compliance officer) can freeze notes viasetFrozenRoot; this is a high-value attack target and trusted role. Multisig / timelock SHOULD be used;FrozenRootUpdatedlogs SHOULD be public. Deployments commonly assign both compliance and asset management to the sameadminat construction; implementations that require separation SHOULD transferadminto an independent compliance multisig. - Admin privileges:
setGroth16Verifier,transferAdmin+acceptAdmin, andsetMaxActionscan alter verification logic and parameters; SHOULD be governance-constrained (multisig + timelock). When rotating verifiers, the new verifier’sActionPubHashMUST match the circuit. Two-step admin transfer prevents accidental lockout.
Privacy Considerations
- Recipient anonymity: operations SHOULD be submitted via a relayer to hide the submitter EOA; direct self-submission links the EOA to the operation.
- Amount visibility:
mint/burnamountandtotalSupplyare public; transfer amounts are private. - Anonymity set: V1 uses per-asset pools; new assets have small anonymity sets. Cross-asset shared anonymity (shared tree +
asset_id) is a future enhancement requiring circuit changes. - Compliance vs. privacy: the
cmxFrozenRootmechanism can freeze identified specific notes, but (a) locatingcmxtypically requires viewing-key disclosure or off-chain intelligence, and (b) if a target note was transferred before freezing, value moves to a newcmxrequiring re-identification. Mandatory compliance roots are an explicit design choice; implementations MAY keepcmxFrozenRoot == 0(empty blacklist = default allow) to approximate trustlessness.