Author: Cyimon (@Cyimon) · Status: Draft · Type: Standards Track (ERC) · EIP: 8287 (PR) · Created: 2026-06-09
Description: A fungible token standard for the EVM that is private by default.
Discussion: ethresear.ch #25089 · Ethereum Magicians #28702 · Implementation: PERC20Labs/pERC20_
We extend the pERC20 protocol design published earlier (Ethereum Magicians #28702 / ethresear.ch #25089). The main addition in this revision is ERC-20 approved spending — approve, 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 section.
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 (mint − burn) |
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 above). Methods marked off-chain in that table have no contract entrypoint; behavior is specified in Method Semantics below.
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
transferMUST returntrue. - 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 iffrootwas 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 repository.
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);amountpublic, recipient private. Mint MUST use the same anchor / nullifier / spend-auth path astransfer(no output-only branch). The circuit MUST constrain the consumed input tov = 0so the action represents net inflow; the contract does not read note value directly.burn:totalSupply -= amount; any holder MAY burn own notes;amountpublic, 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 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. ZIP-32 defines the key derivation; this ERC maps ERC-20 approve / transferFrom onto it as follows:
approve(spender, N)— Owner derives an unused ZIP-32 subaccount, funds it withNviatransfer(PrivacyCall), and delivers that subaccount’s spending key to the EOA spender (encrypted off-chain). On-chain: onetransfer.allowance(owner, spender)— Remaining balance of that subaccount, scanned with the subaccount viewing key. No on-chain mapping.transferFrom(owner, to, amount)— Spender spends from the subaccount to payto; change returns to the subaccount. On-chain: onetransfer.approve(spender, 0)/ revoke — Owner sweeps the subaccount back viatransfer.
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
nfMUST 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
Perc20Createdonce 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/transferFromremoves approval metadata ERC-20 unavoidably leaks. - Approved spending via ZIP-32 subaccounts. Each EOA spender gets an isolated hierarchical account instead of an on-chain
allowancemapping; 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; see 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 deployedPERC20, covering mint, transfer, burn, andapprove/transferFromflows.
Reference Implementation
Code
Reference implementation: PERC20Labs/pERC20_.
- Normative asset contract:
contracts/ptoken/PERC20.sol(IPERC20). - Cryptographic and wallet formats (key derivation,
perc1addresses, note encryption, nullifiers,approvepackaging): reference libraries and SDK in the same repository.
Related Standards and Protocols
- EIP-20: Token Standard — the public fungible token interface that
pERC20maps 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
nfderivation. EachpubFields[i]MUST be< Fr(otherwisenf + Frreuses a proof with a differentisSpentkey);pubFields[0],[3],[6]MUST equalanchor,nfOld,cmxrespectively (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 allnf/cmx. - Subaccount spending keys: keys delivered for
approveMUST only appear as ciphertext; never in contract storage. - Compliance authority:
setFrozenRootis a high-trust admin role; SHOULD use multisig/timelock.
Privacy Considerations
- Operations SHOULD be submitted via a relayer to hide the submitter EOA.
mint/burnamountandtotalSupplyare public; transfer amounts andapproverelationships are private.approve/transferFromare on-chain indistinguishable fromtransfer(sametransfer(PrivacyCall)selector);mint/burnare separate functions with public amounts.- Trial-decryption is the trust boundary for receipt: a
NoteAddedevent alone does not prove payment; the circuit does not verify thatencCiphertextmatchescmx. setFrozenRootlets 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-8287: Private Token Standard,” Ethereum Improvement Proposals, June 2026.