--- eip: 8302 title: Private Fungible Tokens description: A fungible token for the EVM that is private by default author: Cyimon (@Cyimon) discussions-to: https://ethereum-magicians.org/t/perc20-private-token-standard/28796 status: Draft type: Standards Track category: ERC created: 2026-06-16 --- ## Abstract This document defines [ERC-8302](./eip-8302.md), a **private-by-default fungible token** for the EVM: the privacy version of [ERC-20](./eip-20.md). Under the hood it uses the Orchard shielded-pool model from the Zcash Protocol Specification. It keeps the [ERC-20](./eip-20.md) 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 [Interface Overview](#interface-overview). ## Motivation Ethereum's public ledger makes every [ERC-20](./eip-20.md) 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](./eip-8182.md), for example, defines a **protocol-enshrined shielded pool**: users deposit public ETH or compatible [ERC-20](./eip-20.md) 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**. This ERC fills the latter gap. It is an application-layer token 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](./eip-20.md) — same method surface, different openness — so issuers can launch private assets today while protocol-level privacy (e.g. [EIP-8182](./eip-8182.md)) evolves in parallel. The two are complementary, not competing: [EIP-8182](./eip-8182.md) privatizes public assets; this ERC 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](#reference-implementation). ### Interface Overview This section lists **all interfaces** in one place and marks whether each corresponds to [ERC-20](./eip-20.md). **Base token API**: `yes` = [ERC-20](./eip-20.md); `extension` = common extension (mint/burn); `no` = defined only by this ERC. **Layer**: `on-chain` = contract ABI; `off-chain` = wallet/SDK (no contract method). | Interface | Base token API | Layer | Openness | Description | |---|---|---|---|---| | `name()` / `symbol()` / `decimals()` | yes | on-chain | public | Identical to [ERC-20](./eip-20.md) | | `totalSupply()` | yes | on-chain | public | Public counter (`mint` − `burn`) | | `balanceOf(addr)` | yes | off-chain | **private** | Scan Orchard notes (Zcash Protocol Specification) 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 | Event | Base token API | 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](./eip-20.md) operation is being performed. **Not supported natively.** [ERC-20](./eip-20.md)'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 The on-chain ABI is defined below (see [Interface Overview](#interface-overview)). Methods marked **off-chain** in that table have no contract entrypoint; behavior is specified in [Method Semantics](#method-semantics). ```solidity 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; } // Public views aligned with base token API 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. verifier base + asset contract), 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](#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](#reference-implementation). ### Method Semantics #### `name` / `symbol` / `decimals` / `totalSupply` Identical to [ERC-20](./eip-20.md): public on-chain views. #### `transfer(PrivacyCall) → bool` Spends input Orchard notes (Zcash Protocol Specification) 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` (Zcash Protocol Specification), 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 (Zcash Protocol Specification) 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 is built on ZIP-32 hierarchical accounts: each **EOA 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. This ERC maps [ERC-20](./eip-20.md) `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 approved-spending 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](#call-format) (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](./eip-20.md), 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](./eip-20.md) unavoidably leaks. - **Approved spending via ZIP-32 subaccounts**. Each EOA 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](#reference-implementation). ## Backwards Compatibility This ERC is **capability-complete** with [ERC-20](./eip-20.md) but **not byte-compatible**: no public `balanceOf`, no on-chain `allowance`, no `approve`/`transferFrom` ABI, no `Transfer`/`Approval` events. Existing [ERC-20](./eip-20.md) indexers and composable contracts cannot drive it without a privacy-aware wallet/SDK. Optional `bridgeOut` to a public [ERC-20](./eip-20.md) twin MAY terminate privacy at exit; not required by this proposal. ## Test Cases The [Reference Implementation](#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 asset contract, covering mint, transfer, burn, and `approve` / `transferFrom` flows. ## Reference Implementation ### Code Reference implementation: GitHub repository `PERC20Labs/pERC20_`. - Normative asset contract: `contracts/ptoken/PERC20.sol`. - Cryptographic and wallet formats (key derivation, `perc1` addresses, note encryption, nullifiers, approved-spending packaging): reference libraries and SDK in the same repository. ### Related Standards and Protocols - [ERC-20](./eip-20.md): Token Standard — the public fungible token interface that this ERC 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`. - **Approved-spending keys**: subaccount spending keys 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 approved-spending 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](../LICENSE.md).