pERC20: Private Token Standard

Author: Cyimon (@Cyimon) · Status: Draft · Type: Standards Track · Category: ERC · EIP: 8287 · Created: 2026-06-09

Description: A fungible token standard for the EVM that is private by default.

Related discussion: EIP-8287-Draft · Ethereum Magicians #28702 · ethresear.ch #25089 · Implementation: PERC20Labs/pERC20_


We extend the pERC20 protocol design published earlier (Ethereum Magicians #28702). The main addition in this revision is ERC-20 approved spendingapprove, allowance, and transferFrom — via ZIP-32 subaccounts. The updated standard is capability-complete with ERC-20 but not byte-compatible (different ABI, no public balances).

ERC-20 pERC20 Layer
name / symbol / decimals / totalSupply yes — same public views on-chain
balanceOf yes — holder-only scan off-chain
transfer yes — private parties & amount on-chain
approve / allowance / transferFrom new — approved spending for EOA spenders via ZIP-32 subaccounts; on-chain = transfer (contract spenders not supported) off-chain
mint / burn yes — common extension on-chain
Transfer / Approval events omitted (privacy)

Below is the latest pERC20 standard: Private Token Standard.


Abstract

pERC20 is a private-by-default fungible token standard for the EVM: the privacy version of ERC-20. Under the hood it uses the Orchard shielded-pool model from the Zcash Protocol Specification. It keeps ERC-20’s full method surface, but several methods are private (holder-only, off-chain) rather than public on-chain reads, and transfer / approve / transferFrom all appear on-chain as the same transfer operation. See pERC20 Interface below.

Motivation

Ethereum’s public ledger makes every ERC-20 balance, transfer, and allowance permanently visible. As payments, payroll, treasury, and on-chain finance move to L1, users and issuers need private fungible tokens — not just private messaging around public balances.

Privacy is increasingly addressed at the protocol layer. EIP-8182, for example, defines a protocol-enshrined shielded pool: users deposit public ETH or compatible ERC-20 tokens, move value privately inside the shared pool, and withdraw back to public form. That model shields existing public assets; it does not define how to issue a token that is private from creation.

pERC20 fills the latter gap. It is an application-layer token standard for natively private fungible tokens: minted, held, transferred, and spent via approve / transferFrom as private notes from the start, with no public balanceOf phase and no deposit into a shared shielded pool. It specifies the private counterpart to ERC-20 — same method surface, different openness — so issuers can launch private assets today while protocol-level privacy (e.g. EIP-8182) evolves in parallel. The two are complementary, not competing: EIP-8182 privatizes public assets; pERC20 defines private asset issuance.

Specification

The key words MUST, MUST NOT, SHOULD, MAY are interpreted per RFC 2119. Solidity syntax is 0.8.20 or above.

Underlying Protocol

Value is held in shielded notes, not account balances. The note format, nullifiers, commitment tree, note encryption, and action/bundle structure follow the Orchard shielded pool in the Zcash Protocol Specification, adapted here as a per-asset EVM contract verified with Groth16. Field-level formats not repeated below are normative in the Reference Implementation.

pERC20 Interface

This section lists all pERC20 interfaces in one place and marks whether each corresponds to a ERC-20 standard interface. ERC-20: yes = ERC-20 standard; extension = common extension (mint/burn); no = pERC20-specific. Layer: on-chain = contract ABI; off-chain = wallet/SDK (no contract method).

pERC20 interface ERC-20 Layer Openness Description
name() / symbol() / decimals() yes on-chain public Identical to ERC-20
totalSupply() yes on-chain public Public counter (mintburn)
balanceOf(addr) yes off-chain private Scan Orchard notes with viewing key; holder-only
transfer(PrivacyCall) yes on-chain private (parties + amount) Orchard action bundle
approve(spender, N) yes off-chain private (relationship hidden) ZIP-32 subaccount; fund + deliver key; on-chain submits as transfer(PrivacyCall)
allowance(owner, spender) yes off-chain private Scan subaccount remaining balance
transferFrom(from, to, amount) yes off-chain private (relationship hidden) Spender spends subaccount; on-chain submits as transfer(PrivacyCall)
mint(amount, PrivacyCall) extension on-chain amount public; recipient private Issuer-only; Orchard action + totalSupply increase
burn(amount, PrivacyCall) extension on-chain amount public; burner private Holder burns own notes; Orchard action + totalSupply decrease
issuer() no on-chain public Token issuer address
cmxFrozenRoot() / setFrozenRoot() no on-chain public root; admin write Compliance frozen-note root
cmxRoot() / isValidAnchor() / isSpent() / treeSize() no on-chain public Orchard commitment-tree state

Events

pERC20 event ERC-20 Layer Description
Transfer(from, to, value) yes off-chain (omitted) Not emitted; parties and amount are private
Approval(owner, spender, value) yes off-chain (omitted) Not emitted; would link owner ↔ spender
NoteAdded / NoteConfirmed replaces Transfer on-chain Per-note observability
Mint / Burn extension on-chain Public amount only
Perc20Created / FrozenRootUpdated / BundleExecuted no on-chain Deployment, compliance, bundle metadata

On-chain indistinguishability. transfer, the funding step of approve, transferFrom, and revoke-sweep are all the same on-chain call: transfer(PrivacyCall). Observers cannot tell which ERC-20 operation is being performed.

Not supported natively. ERC-20’s approve(contractAddress, amount) — a contract autonomously calling transferFrom — has no native equivalent: spending requires a private key, which a contract cannot hold. See Rationale.

Contract Interface

pERC20 exposes one on-chain ABI (IPERC20; see pERC20 Interface). Methods marked off-chain in that table have no contract entrypoint; behavior is specified in Method Semantics.

interface IPERC20 {
    struct PrivacyCall  { bytes actions; uint256[3] bindingSig; }
    struct BundleAction {
        bytes32    cmx;
        bytes      encCiphertext;
        bytes      outCiphertext;
        bytes32    epk;
        bytes32    nfOld;          // nullifier of the consumed (or dummy) input note
        bytes32    anchor;         // historical root of the consumed (or dummy) input note
        bytes      proof;
        uint256[8] pubFields;
        uint256[3] spendAuthSig;
    }

    // ERC-20-aligned public views
    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 issuer()      external view returns (address);

    // Value-changing operations (private parties; see Method Semantics)
    function transfer(PrivacyCall calldata call) external returns (bool success);
    function mint(uint256 amount, PrivacyCall calldata call) external;
    function burn(uint256 amount, PrivacyCall calldata call) external;

    // Compliance
    function cmxFrozenRoot() external view returns (uint256);
    function setFrozenRoot(uint256 newRoot) external; // onlyAdmin

    // Note state machine (Orchard commitment tree)
    function cmxRoot()                 external view returns (bytes32);
    function isValidAnchor(bytes32 root) external view returns (bool);
    function isSpent(bytes32 nf)         external view returns (bool);
    function treeSize()                  external view returns (uint256);

    event Mint(address indexed issuer, uint256 amount);
    event Burn(uint256 amount);
    event FrozenRootUpdated(uint256 oldRoot, uint256 newRoot);
    event Perc20Created(
        address indexed pool, address indexed issuer,
        string name, string symbol, uint8 decimals
    );
    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);
}

Conformance:

  • A successful transfer MUST return true.
  • The core bundle execution path MUST NOT be publicly callable (supply invariant; see below).
  • Implementations MAY split Solidity into multiple contracts (e.g. IPERC20 + verifier base), but the observable ABI and events MUST match the unified interface above.
  • cmxRoot() is the latest commitment-tree root; isValidAnchor(root) returns true iff root was ever active; isSpent(nf) exposes the nullifier set; treeSize() is the number of inserted commitments.

Call Format

Every value-changing operation submits a PrivacyCall encoding one or more Orchard actions (Zcash Protocol Specification):

  • actions = abi.encode(BundleAction[]).
  • bindingSig = Schnorr binding signature [Rx, Ry, s] proving value conservation.

Each BundleAction is an Orchard action adapted for EVM verification: one output note commitment (cmx) plus proof material for its (real or dummy) input. pubFields MUST be ordered as follows (Orchard Action primary inputs):

Index Field Role
[0] anchor Merkle root of the consumed input
[1] cv_net_x Net value commitment X (binding signature)
[2] cv_net_y Net value commitment Y (binding signature)
[3] nf_old Nullifier of the consumed input
[4] rk_x Randomised spend-auth key X
[5] rk_y Randomised spend-auth key Y
[6] cmx Output note commitment
[7] rt_frozen Compliance frozen-root binding

Each pubFields[i] MUST be < Fr, where Fr is the scalar field modulus of the SNARK curve used by the verifier (BN254 in the Reference Implementation); otherwise revert (PubFieldOutOfRange). Implementations MUST hash the eight fields to one Groth16 public signal via ActionPubHash (Poseidon sponge), matching the circuit’s PubHashAction().

Binding to calldata fields. The proof public inputs MUST match the action’s top-level fields; implementations MUST revert if any of the following fail:

Check Revert
pubFields[0] == anchor and isValidAnchor(anchor) BadAnchor
pubFields[3] == nfOld MUST revert (reference impl: NullifierSpent)
pubFields[6] == cmx and cmx != 0 InvalidProof / ZeroCommitment
pubFields[7] == cmxFrozenRoot() BadFrozenRoot
spendAuthSig verifies under pubFields[4], pubFields[5] over (nfOld, cmx, epk, encCiphertext, outCiphertext) BadSpendAuthSig

Without the pubFields ↔ calldata equality checks, a valid proof could be replayed with a different nfOld or cmx, bypassing the nullifier set or inserting an unproven commitment.

encCiphertext MUST be 580 bytes (Orchard note plaintext + AEAD tag under the recipient key). outCiphertext SHOULD be 80 bytes (sender self-recovery under OVK). Implementations that change note encryption layout MUST publish a separate variant of this ERC.

The bundle-level bindingSig MUST be verified over all action nullifiers, commitments, and the operation’s valueBalance before any state mutation (see Method Semantics for encodings).

Note encryption and key derivation follow the Orchard note format in the Zcash Protocol Specification; exact encodings are in the Reference Implementation.

Method Semantics

name / symbol / decimals / totalSupply

Identical to ERC-20: public on-chain views.

transfer(PrivacyCall) → bool

Spends input Orchard notes and creates output notes in a value-conserving action bundle (valueBalance == 0). Sender, recipient, and amount MUST remain private. Returns true on success; emits NoteAdded / NoteConfirmed, not Transfer(from,to,value).

mint(amount, PrivacyCall) / burn(amount, PrivacyCall)

Same Orchard action verification path as transfer, with public totalSupply accounting:

  • mint: totalSupply += amount (onlyIssuer); amount public, recipient private. Mint MUST use the same anchor / nullifier / spend-auth path as transfer (no output-only branch). The circuit MUST constrain the consumed input to v = 0 so the action represents net inflow; the contract does not read note value directly.
  • burn: totalSupply -= amount; any holder MAY burn own notes; amount public, burner private.
Operation valueBalance encoding
transfer 0
burn bit255 = 0, low bits = amount
mint bit255 = 1, low bits = amount

balanceOf (off-chain, private)

Only the holder can compute their balance by scanning NoteAdded events, trial-decrypting Orchard notes with their viewing key, and excluding spent nullifiers. There is no on-chain balanceOf and no way to query a third party’s balance.

approve / allowance / transferFrom (off-chain semantics; on-chain = transfer)

Approved spending (approve / allowance / transferFrom) is built on ZIP-32 hierarchical accounts: each spender receives a dedicated subaccount (account_S) with its own spending and viewing keys, cryptographically isolated from the owner’s main account and from every other spender. ZIP-32 defines the key derivation; this ERC maps ERC-20 approve / transferFrom onto it as follows:

  1. approve(spender, N) — Owner derives an unused ZIP-32 subaccount, funds it with N via transfer(PrivacyCall), and delivers that subaccount’s spending key to the spender (encrypted off-chain). On-chain: one transfer.
  2. allowance(owner, spender) — Remaining balance of that subaccount, scanned with the subaccount viewing key. No on-chain mapping.
  3. transferFrom(owner, to, amount) — Spender spends from the subaccount to pay to; change returns to the subaccount. On-chain: one transfer.
  4. approve(spender, 0) / revoke — Owner sweeps the subaccount back via transfer.

The allowance ceiling is enforced by the subaccount’s actual note balance, not an on-chain counter. Wallets distinguish “own” vs “allowance” assets by which viewing key decrypted the note — no on-chain marker.

Execution Requirements

The on-chain state machine follows the Orchard shielded pool (Zcash Protocol Specification). Implementations MUST:

  • Maintain a nullifier set; the same nf MUST NOT be spent twice.
  • Maintain an append-only commitment tree; historical roots queryable via isValidAnchor.
  • Verify Groth16 proofs, spend-auth and binding signatures, and all pubFields binding checks (not only pubFields[7]).
  • Reject duplicate or zero commitments; reject empty action arrays; cap actions per call (maxActions, a finite configurable positive bound).
  • Expose value changes only through mint / burn / transfer (no public bundle entrypoint).
  • Emit Perc20Created once at deployment (factory deployment is RECOMMENDED but not required).

Compliance. cmxFrozenRoot() is the root of an off-chain blacklist SMT; the circuit proves non-membership of spent notes. setFrozenRoot is admin-only; initial root 0 denotes an empty blacklist. Implementations MAY accept the immediately previous root during a short grace window after an update so in-flight proofs are not stranded.

Rationale

  • Orchard ZK-UTXO model. Notes, nullifiers, and the commitment tree follow the Zcash Protocol Specification; this ERC defines the private token interface and per-asset deployment on EVM.
  • Private ERC-20, not a different asset. Same method surface; privacy changes openness (public view vs private query vs indistinguishable transfer), not user intent.
  • One on-chain operation for all movement. Collapsing transfer / approve / transferFrom removes approval metadata ERC-20 unavoidably leaks.
  • Approved spending via ZIP-32 subaccounts. Each spender gets an isolated hierarchical account instead of an on-chain allowance mapping; see Method Semantics.
  • No approve(contract). A contract has no private key; placing a spending key on-chain would expose it to everyone. Programmable private spending (predicate-authorized notes, MPC custody) is future work, not part of this ERC.
  • Wallet behavior is out of scope of the on-chain ABI. Subaccount layout, encrypted key delivery, and note scanning are wallet/SDK responsibilities; a conformant reference is provided in the Reference Implementation.

Backwards Compatibility

pERC20 is capability-complete with ERC-20 but not byte-compatible: no public balanceOf, no on-chain allowance, no approve/transferFrom ABI, no Transfer/Approval events. Existing ERC-20 indexers and composable contracts cannot drive it without a privacy-aware wallet/SDK.

Optional bridgeOut to a public ERC-20 twin MAY terminate privacy at exit; not required by this proposal.

Test Cases

The Reference Implementation repository includes:

  • Foundry unit tests (test/PERC20Test.t.sol): constructor guards, mint/burn/transfer accounting, supply invariants.
  • End-to-end tests (test/PERC20E2E.t.sol, e2e/): real Groth16 proofs against a deployed PERC20, covering mint, transfer, burn, and approve / transferFrom flows.

Reference Implementation

Code

Reference implementation: PERC20Labs/pERC20_.

  • Normative asset contract: contracts/ptoken/PERC20.sol (IPERC20).
  • Cryptographic and wallet formats (key derivation, perc1 addresses, note encryption, nullifiers, approve packaging): reference libraries and SDK in the same repository.

Related Standards and Protocols

  • EIP-20: Token Standard — the public fungible token interface that pERC20 maps to.
  • Zcash Protocol Specification: Orchard shielded pool — note commitments, nullifiers, note encryption, and action structure adapted here for EVM Groth16 verification.
  • ZIP-32: Shielded Hierarchical Deterministic Wallets — hierarchical account derivation used for per-spender subaccounts in approve / transferFrom.

Security Considerations

  • Double-spend protection: nullifier set + correct nf derivation. Each pubFields[i] MUST be < Fr (otherwise nf + Fr reuses a proof with a different isSpent key); pubFields[0], [3], [6] MUST equal anchor, nfOld, cmx respectively (otherwise a valid proof can be bound to different calldata).
  • Supply invariant: value changes only via mint/burn/transfer; core execution path MUST NOT be publicly callable.
  • Value conservation: binding signatures verified before state mutation.
  • Replay protection: sighash binds chainId, contract address, and all nf/cmx.
  • Subaccount spending keys: keys delivered for approve MUST only appear as ciphertext; never in contract storage.
  • Compliance authority: setFrozenRoot is a high-trust admin role; SHOULD use multisig/timelock.

Privacy Considerations

  • Operations SHOULD be submitted via a relayer to hide the submitter EOA.
  • mint/burn amount and totalSupply are public; transfer amounts and approve relationships are private.
  • approve/transferFrom are on-chain indistinguishable from transfer (same transfer(PrivacyCall) selector); mint/burn are separate functions with public amounts.
  • Trial-decryption is the trust boundary for receipt: a NoteAdded event alone does not prove payment; the circuit does not verify that encCiphertext matches cmx.
  • setFrozenRoot lets an admin freeze identified notes via an off-chain blacklist; this is an explicit compliance trade-off against full trustlessness.

Copyright

Copyright and related rights waived via CC0.

Please cite this document as: Cyimon, “ERC-7605: Private Token Standard,” Ethereum Improvement Proposals, June 2026.