--- eip: 7605 title: Privacy-Native Fungible Tokens description: Privacy-native fungible token interface on EVM powered by the Zcash Orchard protocol author: Cyimon (@Cyimon) discussions-to: https://ethresear.ch/t/perc20-privacy-native-fungible-token-standard-draft/25089 status: Draft type: Standards Track category: ERC created: 2026-06-03 --- ## Abstract This document is [ERC-7605](./erc-7605.md). A privacy-native fungible token interface on the EVM using ZK-UTXO notes, with public `totalSupply` and private balances and transfers. Implementations provide transfer, mint, and burn with private balances and transfer amounts 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 proposal is **not** binary-compatible with [ERC-20](./eip-20.md): there is no public `balanceOf`, and no `approve` / `allowance`. Instead it defines an equivalent privacy interface, `IPERC20`. This proposal is complementary to protocol-level private transfer layers for ETH and compatible [ERC-20](./eip-20.md) assets, while `pERC20` defines a privacy-native token interface at the asset level. ## Motivation Ethereum is increasingly focused on **native privacy** at the protocol layer. As base-layer privacy primitives mature, the ecosystem will need a **privacy-native fungible token interface** with a role analogous to [ERC-20](./eip-20.md) for public tokens. This document proposes **`pERC20`** to fill that gap: a normative 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](./eip-20.md) 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 ERC) | | **`frozenRoot` / `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. ```solidity struct PrivacyCall { bytes actions; // = abi.encode(BundleAction[]) uint256[3] bindingSig; // [Rx, Ry, s] Baby JubJub Schnorr binding signature } ``` #### BundleAction Implementation-layer action for `IEndpointCore`. ```solidity 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 (or circuit-constrained dummy) 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 the Zcash Protocol Specification §7.5 Action Description Encoding and Consensus (zips.z.cash protocol PDF, action 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 ERC 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 the Zcash Protocol Specification §4.18.4 Action Statement (Orchard) (zips.z.cash protocol PDF, action statement) (`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 `isSpent` key, enabling double-spend of the same note. ### The `IPERC20` interface #### Methods **NOTES**: - 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"`. ```solidity function name() external view returns (string memory) ``` #### symbol Returns the symbol of the token. E.g. `"pHIX"`. ```solidity 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. ```solidity 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. ```solidity function totalSupply() external view returns (uint256) ``` #### issuer Returns the address authorized to mint under this standard. ```solidity function issuer() external view returns (address) ``` #### cmxFrozenRoot Returns the current compliance frozen root (`IPERC20` interface method). ```solidity function cmxFrozenRoot() external view returns (uint256) ``` #### setFrozenRoot Updates the compliance frozen root after rebuilding the off-chain blacklist SMT. MUST be restricted to `admin`. ```solidity 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](#supply-invariant)). ```solidity 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. ```solidity 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. ```solidity function burn(uint256 amount, PrivacyCall calldata call) external ``` ### Events #### Mint MUST trigger when new tokens are minted. ```solidity event Mint(address indexed issuer, uint256 amount) ``` #### Burn MUST trigger when tokens are burned. ```solidity event Burn(uint256 amount) ``` #### FrozenRootUpdated MUST trigger when the compliance frozen root changes. ```solidity event FrozenRootUpdated(uint256 oldRoot, uint256 newRoot) ``` #### `Perc20Created` event 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 a `Perc20Created` event (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. ```solidity 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): ```solidity 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: ```solidity interface IEndpointCore { function activeRoot() 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 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); } ``` Implementations MUST: - Prevent double-spending via a nullifier set; the same `nf` MUST NOT be spent twice. - 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) `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 protocol-level private transfer infrastructure. 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 proposal). ## Test Cases The reference implementation in the PERC20 repository (GitHub: PERC20Labs/PERC20) includes: - Foundry unit tests (`test/PERC20Test.t.sol`) for constructor guards, mint/burn/transfer accounting, and supply invariants. - A no-mock end-to-end suite (`test/PERC20E2E.t.sol`, `e2e/`) that generates real Groth16 proofs and exercises mint, transfer, and burn against a deployed `PERC20` contract. See `docs/e2e-report.md` in that repository for scenario-to-spec mapping and findings. ## Reference Implementation Reference implementation: GitHub repository PERC20Labs/pERC20_ (minimal reference asset contract). - Normative asset contract: `contracts/ptoken/PERC20.sol` in that repository (`IPERC20` reference). `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 `isSpent` is set, 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 `isSpent` (see [Data Structures](#data-structures)). - **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**: per-asset pools mean new assets start with 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. ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md). Please cite this document as: Cyimon, "ERC-7605: Privacy-Native Fungible Tokens," *Ethereum Improvement Proposals*, June 2026.