pERC20: Privacy-Native Fungible Token Standard (Draft)

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: totalSupply is 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 from epk and 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_frozen replaces enable_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 current cmxFrozenRoot(); 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.
  • setFrozenRoot MUST be restricted to admin (issuer or a dedicated compliance officer, depending on deployment and admin configuration) and SHOULD use multisig / timelock. Each change MUST emit FrozenRootUpdated for auditability.
  • To freeze a note: off-chain, insert its cmx into the blacklist SMT, compute the new root, call setFrozenRoot(newRoot). To unfreeze, remove and call setFrozenRoot again.
  • Initial cmxFrozenRoot == 0 denotes 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): whether root was 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 nf MUST NOT be spent twice. Nullifier membership is not required as a public view function on IEndpointCore.
  • 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 commitments cmx == 0 (ZeroCommitment).
  • Verify that binding / spend-auth signature points R lie 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 input rt_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:

  1. Existing public assets (including DeFi-relevant assets) gain a private transfer path through infrastructure such as EIP-8182.
  2. Privacy-native assets and notes become first-class primitives for new application design.
  3. 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:

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 nf derivation in the circuit. Once a nullifier is marked spent, a note MUST NOT be spendable again. pubFields field-range checks (< Fr) are a prerequisite for double-spend protection; otherwise nf + Fr can reuse the same proof while bypassing the nullifier set (see Data Structures above).
  • Supply invariant: asset contracts with totalSupply accounting MUST expose value changes only through mint / burn / transfer; the core execution path MUST NOT be publicly callable, otherwise anyone could inject unaccounted value without increasing totalSupply.
  • Value conservation: relies on binding signatures and a NUMS generator (log_{G_RANDOM}(G_VALUE) unknown). Consistency between amount and valueBalance is enforced by comparing low 255 bits. Signature points R MUST be curve- and field-validated.
  • Replay protection: sighash domain separation binds chainId, contract address, and all nf / cmx values, preventing cross-chain, cross-contract, and cross-bundle replay.
  • Anchor validity: isValidAnchor uses 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 via setFrozenRoot; this is a high-value attack target and trusted role. Multisig / timelock SHOULD be used; FrozenRootUpdated logs SHOULD be public. Deployments commonly assign both compliance and asset management to the same admin at construction; implementations that require separation SHOULD transfer admin to an independent compliance multisig.
  • Admin privileges: setGroth16Verifier, transferAdmin + acceptAdmin, and setMaxActions can alter verification logic and parameters; SHOULD be governance-constrained (multisig + timelock). When rotating verifiers, the new verifier’s ActionPubHash MUST 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 / burn amount and totalSupply are 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 cmxFrozenRoot mechanism can freeze identified specific notes, but (a) locating cmx typically requires viewing-key disclosure or off-chain intelligence, and (b) if a target note was transferred before freezing, value moves to a new cmx requiring re-identification. Mandatory compliance roots are an explicit design choice; implementations MAY keep cmxFrozenRoot == 0 (empty blacklist = default allow) to approximate trustlessness.
1 Like

Right off the bat, this has to change. Ethereum can’t approve a non-quantum-safe EIP at this point. For clarity, I’m not an EF member but I understand this is very much the position of the EF.

FTR I’m currently working on a quantum-safe version of Circom that should simplify the development of this proposal a lot, but it’s totally based on PLONK.

I’m using DEEP-FRI as the underlying PCS and that’s probably not ideal because WHIR proofs would be smaller, but I need to keep the first prototype simple and verifier complexity can be adjusted by fine-tuning the blowup parameter anyway.

So, there should be an EIP: support FRI precomplie

We don’t need a precompile, a DEEP-FRI verifier in Solidity is completely feasible. In fact, here it is: GitHub - libernet-xyz/evm-verifier: Solidity verifier for Starkom proofs. · GitHub

Great. What about the gas cost? Curse in this EIP, we focus more on the privacy side. The proof system is only the backend which could be optimized in the future.

It depends on the circuit size and blowup factor. The circuit size in turn depends on the arithmetization scheme.

The current prototype uses vanilla PLONK (3 columns), so all circuits are going to be fairly tall. A farily large, 256k-row circuit needs an evaluation domain size of 1M after applying a blowup factor of 4. That results in 2*20*20 = 800 hashes in each FRI query, and DEEP-FRI needs 64 of those queries to achieve 128-bit security. So we’re at 51200 hashes. If a single SHA2 hash costs 60 gas units on the EVM, that makes for a gas cost of about 3M. For reference, the block-level gas limit is 60M.

While that may seem like a lot I need to stress that:

  1. This is not optional. The quantum threat is closing in, and even if you believe a CRQP will never exist, its fear exists. Ethereum can no longer keep building on elliptic curves.
  2. After implementing the Circom-like parser I’ll upgrade my engine to TurboPLONK, which will drastically reduce the number of rows. For reference, a Poseidon2 hash with T=3 takes 640 rows on vanilla PLONK and should take 256 rows on TurboPLONK, achieving a 60% reduction.
  3. In the future I’ll probably swap FRI for WHIR or something more efficient.

It sounds great. Is it open-sourced now? Seems that move to FRI-based proof system is necessary.

Yes it is! It’s not a single repo though, I made several reusable components. The whole system is called Starkom – as in… Circom on zkSTARKs. :grinning_face_with_smiling_eyes:

The components I’ve published so far are:

The system is already functional up to the PLONK layer, so you can already build, prove, and verify circuits in Rust if you want. The gadgets crate provides examples of how to build circuits, eg. this is Poseidon2: gadgets/src/poseidon2.rs at ee6062fb28968c8b520888c2b4c56e0c53defa34 · libernet-xyz/gadgets · GitHub

But I haven’t finished the Starkom language compiler yet, I’m working on it here: GitHub - libernet-xyz/starkom: The Starkom language compiler. · GitHub

FTR the whole system compiles to WebAssembly just fine, so it’s going to be easily reusable in JavaScript – yes, even the Starkom compiler itself!

1 Like