| Field | Value |
|---|---|
| Status | Draft |
| Type | Standards Track |
| Category | ERC |
| Requires | EIP-712, ERC-5564 |
Abstract
This ERC defines a privacy-preserving ERC-1155 multi-token standard. Per-address, per-tokenId balances are hidden using Poseidon hash commitments, while validity is verified through on-chain Groth16 ZK proofs. Token types (tokenId) and total supplies remain public.
The standard extends the VOSA (Virtual One-time Sub-Account) model to multi-token — each (address, tokenId) pair stores one commitment hiding the balance amount. Ownership is proven via standard ECDSA signatures (MetaMask compatible, no custom wallet needed).
Privacy Model: Amounts and per-address holdings are hidden. Token types, transfer graph (which VOSAs transact), and total supply per tokenId remain visible. This enables regulatory compliance while protecting balance privacy.
Key Features:
- Per-tokenId balance privacy (amounts hidden in Poseidon commitments)
- Arbitrary transfer amounts per tokenId (not fixed denominations)
- On-chain Groth16 ZK proof verification (fully decentralized)
- Standard ECDSA signatures + EVM-native 20-byte addresses
- O(1) spent-tracking with configurable epoch cleanup
- Batch minting across multiple tokenIds
- Policy (recurring) and Permit (one-time) authorization per tokenId
Motivation
The Privacy Gap in ERC-1155
ERC-1155 exposes all holding information:
balanceOf(0x1234, tokenId=42) → 500 // Anyone can see holdings per item
TransferSingle(from, to, id, 100) // Amount is public
This creates problems in multi-token scenarios:
- Gaming: Opponents can see your inventory (rare items, power-ups, in-game currency)
- Loyalty programs: Competitors see point balances and redemption patterns
- Tokenized assets: Portfolio composition is fully transparent
- Collectibles: Holdings of limited-edition items are public, enabling targeted social engineering
Why ERC-1155 (Multi-Token) Not ERC-721 (Single NFT)?
ERC-1155 covers the broader use case. Many “NFT” applications actually need fungible quantities per token type:
| Use Case | Token Model | Why 1155? |
|---|---|---|
| Game items | 500 swords, 200 potions | Fungible per type, multiple types |
| Event tickets | 1000 GA, 50 VIP | Quantities per tier |
| Loyalty points | Multiple point categories | Fungible balances per category |
| Tokenized commodities | 100oz gold, 50oz silver | Quantities per asset type |
| Semi-fungible tokens | Edition #1-100 of artwork | Same type, multiple copies |
For truly unique 1-of-1 items, set amount = 1 — the protocol handles it as a special case.
Privacy Comparison
| Solution | Amount Privacy | Holder Identity | Transfer Graph | Token Type Visible | Compliance |
|---|---|---|---|---|---|
| ERC-1155 | |||||
| This standard | |||||
| Full-privacy NFT |
Design Philosophy
This standard targets Balance Privacy, not Anonymous Mixing:
Hide how many of each item you hold
Hide transfer quantities
Regulatory-compliant (fund flow traceable, token types visible)
Hide which token type is being transferred (tokenId is public)
Full anonymity (transfer graph is visible)
Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Overview
┌──────────────────────────────────────────────────────────────────────────┐
│ Private ERC-1155 Architecture │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ Storage: │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ mapping(address => mapping(uint256 => bytes32)) balanceCommitment │ │
│ │ mapping(uint256 => uint256) totalSupply │ │
│ │ mapping(address => mapping(uint256 => PolicyMeta)) policies │ │
│ │ mapping(address => mapping(uint256 => PermitMeta)) permits │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ Operations: │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ mint(recipient, tokenId, amount, commitment, proof) │ │
│ │ mintBatch(recipient, tokenIds[], amounts[], commitments[], ...) │ │
│ │ burn(input, tokenId, amount, proof, signature) │ │
│ │ transfer(tokenId, inputs[], outputs[], proof, signatures) │ │
│ │ consolidate(tokenId, inputs[], output, proof, signatures) │ │
│ │ createPolicy(tokenId, spender, commitment, proof, signature) │ │
│ │ createPermit(tokenId, spender, commitment, proof, signature) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
On-chain state per (address, tokenId):
mapping(address => mapping(uint256 => bytes32)) balanceCommitment;
// bytes32(0) → never used
// Poseidon(tokenId, amount, blinder, ts) → active balance (amount hidden)
// SPENT_PREFIX | block.number → spent (cleanable after epoch window)
Key Difference from Private ERC-20
| Aspect | Private ERC-20 | Private ERC-1155 |
|---|---|---|
| Storage | address → commitment |
address → tokenId → commitment |
| Commitment | Poseidon(amount, blinder, ts) |
Poseidon(tokenId, amount, blinder, ts) |
| Supply | Single totalSupply |
totalSupply[tokenId] per token type |
| Policy/Permit | Per address | Per (address, tokenId) |
| EIP-712 | No tokenId in type hashes |
All type hashes include tokenId |
| Batch mint | N/A | mintBatch() for multiple tokenIds |
| Transfer | Any amount, any token | One tokenId per transfer call |
Constants
bytes16 public constant SPENT_PREFIX = 0xDEADDEADDEADDEADDEADDEADDEADDEAD;
uint256 public constant MAX_INPUTS = 10;
uint256 public constant MAX_OUTPUTS = 10;
uint256 public constant TIMESTAMP_WINDOW = 2 hours;
uint256 public constant DEFAULT_CLEANUP_WINDOW = 216_000; // ~1 month
Data Structures
struct PolicyMeta {
address owner;
address spender;
uint256 expiry; // 0 = never expires
bool revoked;
}
struct PermitMeta {
address owner;
address spender;
uint256 expiry;
bool revoked;
bool used; // Single-use
}
struct CreatePolicyParams {
address input;
bytes32 inputCommitment;
address policyAddress;
bytes32 policyCommitment;
uint256 policyTimestamp;
address changeAddress;
bytes32 changeCommitment;
uint256 changeTimestamp;
address spender;
uint256 expiry;
uint256 deadline;
}
struct CreatePermitParams {
address input;
bytes32 inputCommitment;
address permitAddress;
bytes32 permitCommitment;
uint256 permitTimestamp;
address changeAddress;
bytes32 changeCommitment;
uint256 changeTimestamp;
address spender;
uint256 expiry;
uint256 deadline;
}
Core Interface
Metadata
interface IPrivateERC1155Metadata {
function uri(uint256 tokenId) external view returns (string memory);
function totalSupply(uint256 tokenId) external view returns (uint256);
}
VOSA State Management
interface IPrivateERC1155VOSA {
function balanceCommitment(address vosa, uint256 tokenId) external view returns (bytes32);
function hasBalance(address vosa, uint256 tokenId) external view returns (bool);
function isEverUsed(address vosa, uint256 tokenId) external view returns (bool);
function isSpent(address vosa, uint256 tokenId) external view returns (bool);
function batchHasBalance(address[] calldata addrs, uint256[] calldata tokenIds)
external view returns (bool[] memory);
}
Core Operations
interface IPrivateERC1155Core {
/// @notice Mint tokens — tokenId and amount are public at issuance
function mint(
address recipient, uint256 tokenId, uint256 amount, bytes32 commitment,
uint256 outputTimestamp, bytes calldata ephemeralPubKey,
bytes calldata proof, bytes calldata memo
) external returns (bool);
/// @notice Batch mint across multiple tokenIds
function mintBatch(
address recipient, uint256[] calldata tokenIds, uint256[] calldata amounts,
bytes32[] calldata commitments, uint256[] calldata outputTimestamps,
bytes[] calldata ephemeralPubKeys, bytes[] calldata proofs, bytes calldata memo
) external returns (bool);
/// @notice Burn tokens (full or partial with change)
function burn(
address input, uint256 tokenId, bytes32 inputCommitment, uint256 amount,
address changeAddress, bytes32 changeCommitment, uint256 changeTimestamp,
bytes calldata changeEphemeralPubKey, bytes calldata signature,
bytes calldata proof, uint256 deadline
) external returns (bool);
/// @notice Private transfer (N inputs → M outputs, single tokenId, amounts hidden)
function transfer(
uint256 tokenId, address[] calldata inputs, bytes32[] calldata inputCommitments,
address[] calldata outputs, bytes32[] calldata outputCommitments,
uint256[] calldata outputTimestamps, bytes[] calldata ephemeralPubKeys,
bytes[] calldata signatures, bytes calldata proof, uint256 deadline,
int256[] calldata policyChangeIndices, bytes calldata memo
) external returns (bool);
/// @notice Merge multiple VOSAs of same tokenId into one
function consolidate(
uint256 tokenId, address[] calldata inputs, bytes32[] calldata inputCommitments,
address output, bytes32 outputCommitment, uint256 outputTimestamp,
bytes calldata ephemeralPubKey, bytes[] calldata signatures,
bytes calldata proof, uint256 deadline
) external returns (bool);
}
Authorization Interfaces
Policy (Recurring, per tokenId)
interface IPrivateERC1155Policy {
function createPolicy(
uint256 tokenId, CreatePolicyParams calldata params,
bytes[] calldata ephemeralPubKeys, bytes calldata ownerSignature,
bytes calldata proof, bytes calldata memo
) external returns (bool);
function revokePolicy(address policyAddress, uint256 tokenId) external;
function getPolicy(address policyAddress, uint256 tokenId) external view returns (
address owner, address spender, uint256 expiry, bool revoked, bool isActive
);
}
Permit (One-time, per tokenId)
interface IPrivateERC1155Permit {
function createPermit(
uint256 tokenId, CreatePermitParams calldata params,
bytes[] calldata ephemeralPubKeys, bytes calldata ownerSignature,
bytes calldata proof, bytes calldata memo
) external returns (bool);
function revokePermit(address permitAddress, uint256 tokenId) external;
function getPermit(address permitAddress, uint256 tokenId) external view returns (
address owner, address spender, uint256 expiry, bool revoked, bool used, bool isActive
);
}
Events
interface IPrivateERC1155Events {
event TransferSingle(address indexed operator, address[] inputs, address[] outputs,
uint256 indexed tokenId, bytes32[] outputCommitments,
bytes[] ephemeralPubKeys, bytes memo);
event TransferBatch(address indexed operator, address from, address to,
uint256[] tokenIds, bytes32[] outputCommitments,
bytes[] ephemeralPubKeys, bytes memo);
event Burn(address indexed input, uint256 indexed tokenId, uint256 amount,
address changeAddress, bytes32 changeCommitment, bytes changeEphemeralPubKey);
event Consolidate(uint256 indexed tokenId, address[] inputs, address indexed output,
bytes32 outputCommitment, bytes ephemeralPubKey);
event PolicyCreated(uint256 indexed tokenId, address indexed policyAddress,
address indexed owner, address spender, uint256 expiry,
bytes32 policyCommitment, address changeAddress, bytes32 changeCommitment,
bytes[] ephemeralPubKeys, bytes memo);
event PolicyRevoked(address indexed policyAddress, uint256 indexed tokenId);
event PolicyMigrated(address indexed oldAddress, address indexed newAddress,
uint256 indexed tokenId);
event PermitCreated(uint256 indexed tokenId, address indexed permitAddress,
address indexed owner, address spender, uint256 expiry,
bytes32 permitCommitment, address changeAddress, bytes32 changeCommitment,
bytes[] ephemeralPubKeys, bytes memo);
event PermitUsed(address indexed permitAddress, uint256 indexed tokenId);
event PermitRevoked(address indexed permitAddress, uint256 indexed tokenId);
event AddressCleaned(address indexed addr, uint256 indexed tokenId, uint256 spentBlock);
event MinterUpdated(address indexed newMinter);
event VerifierUpdated(string indexed verifierType, address indexed newVerifier);
event CleanupWindowUpdated(uint256 oldWindow, uint256 newWindow);
}
EIP-712 Type Definitions
All type hashes include tokenId as the first field, binding signatures to a specific token type:
bytes32 constant TRANSFER_1155_TYPEHASH = keccak256(
"Transfer1155(uint256 tokenId,bytes32 inputsHash,bytes32 inputCommitmentsHash,"
"bytes32 outputsHash,bytes32 outputCommitmentsHash,uint256 deadline)"
);
bytes32 constant BURN_1155_TYPEHASH = keccak256(
"Burn1155(uint256 tokenId,address input,bytes32 inputCommitment,uint256 amount,"
"address changeAddress,bytes32 changeCommitment,uint256 changeTimestamp,uint256 deadline)"
);
bytes32 constant CONSOLIDATE_1155_TYPEHASH = keccak256(
"Consolidate1155(uint256 tokenId,bytes32 inputsHash,bytes32 inputCommitmentsHash,"
"address output,bytes32 outputCommitment,uint256 deadline)"
);
bytes32 constant CREATE_POLICY_1155_TYPEHASH = keccak256(
"CreatePolicy1155(uint256 tokenId,address input,bytes32 inputCommitment,"
"address policyAddress,bytes32 policyCommitment,uint256 policyTimestamp,"
"address changeAddress,bytes32 changeCommitment,uint256 changeTimestamp,"
"address spender,uint256 expiry,uint256 deadline)"
);
bytes32 constant CREATE_PERMIT_1155_TYPEHASH = keccak256(
"CreatePermit1155(uint256 tokenId,address input,bytes32 inputCommitment,"
"address permitAddress,bytes32 permitCommitment,uint256 permitTimestamp,"
"address changeAddress,bytes32 changeCommitment,uint256 changeTimestamp,"
"address spender,uint256 expiry,uint256 deadline)"
);
ZK Circuit Specifications
Commitment Format
commitment = Poseidon(tokenId, amount, blinder, timestamp)
Where:
- tokenId: uint256, public (identifies the token type)
- amount: uint256, MUST be < 2^96
- blinder: Field element, MUST NOT be 0
- timestamp: uint256, for replay protection
Key difference from ERC-20: tokenId is included in the commitment. The circuit enforces that all inputs and outputs in a transfer use the same tokenId, preventing cross-token-type manipulation.
Amount1155 Circuit (Mint/Burn)
Public inputs: tokenId, inputCommitments[], outputCommitments[], absAmount,
isWithdraw, outputTimestamps[], txHash
Private inputs: amounts[], blinders[], timestamps[]
Constraints:
1. Each commitment == Poseidon(tokenId, amount, blinder, timestamp)
2. All commitments use the same tokenId
3. Mint: outputAmount == absAmount
4. Burn: inputAmount == absAmount + changeAmount
5. All amounts in [0, 2^96)
6. All blinders ≠ 0
7. txHash binding (includes tokenId)
| Variant | Role | Public Signals | Est. Constraints |
|---|---|---|---|
| Amount_0_1_1155 | Mint | 6 (tokenId, commitment, amount, isWithdraw, timestamp, txHash) | ~900 |
| Amount_1_0_1155 | Full burn | 5 (tokenId, commitment, amount, isWithdraw, txHash) | ~900 |
| Amount_1_1_1155 | Partial burn | 7 (tokenId, 2 commitments, amount, isWithdraw, timestamp, txHash) | ~1,400 |
Transfer1155 Circuit
Public inputs: tokenId, inputCommitments[], outputCommitments[],
outputTimestamps[], txHash
Private inputs: inputAmounts[], inputBlinders[], inputTimestamps[],
outputAmounts[], outputBlinders[]
Constraints:
1. Each inputCommitment == Poseidon(tokenId, inputAmount, inputBlinder, inputTimestamp)
2. Each outputCommitment == Poseidon(tokenId, outputAmount, outputBlinder, outputTimestamp)
3. Same tokenId for all commitments (enforced by circuit)
4. sum(inputAmounts) == sum(outputAmounts) (amount conservation)
5. All amounts in [0, 2^96)
6. All blinders ≠ 0
7. txHash == Poseidon(tokenId, inputCommitments..., outputCommitments...)
| Variant | Role | Est. Constraints |
|---|---|---|
| Transfer_1_2_1155 | 1→2 (transfer + change) | ~1,800 |
| Transfer_2_2_1155 | 2→2 (standard transfer) | ~2,300 |
| Transfer_5_1_1155 | 5→1 (consolidation) | ~3,200 |
Note: The contract allows up to
MAX_INPUTS=10, but the current circuit deployment supports up to 5 inputs. Larger variants can be compiled from the same template.
Poseidon Parameters
Hash: Poseidon
Width: t = 5 (4 inputs + 1 capacity) for commitments; varies for txHash
Rounds: RF = 8 full, RP = 60 partial
Field: BN254 Fr
Authorization Logic
Per-tokenId authorization — each (address, tokenId) has at most one authorized signer:
function _getAuthorizedSigner(address input, uint256 tokenId) internal view returns (address) {
PolicyMeta memory pMeta = _policyMeta[input][tokenId];
PermitMeta memory tMeta = _permitMeta[input][tokenId];
if (pMeta.owner != address(0)) {
bool isActive = !pMeta.revoked && (pMeta.expiry == 0 || block.timestamp < pMeta.expiry);
return isActive ? pMeta.spender : pMeta.owner;
} else if (tMeta.owner != address(0)) {
bool isActive = !tMeta.used && !tMeta.revoked
&& (tMeta.expiry == 0 || block.timestamp < tMeta.expiry);
return isActive ? tMeta.spender : tMeta.owner;
}
return input;
}
Epoch Cleanup
Same as Private ERC-20: SPENT markers encode block.number, deletable after the configurable cleanup window.
function cleanup(address[] calldata addrs, uint256[] calldata tokenIds) external returns (uint256 cleaned);
Rationale
Why Poseidon with 4 Inputs?
ERC-20 commitments use Poseidon(amount, blinder, timestamp) — 3 inputs. ERC-1155 adds tokenId as a 4th input: Poseidon(tokenId, amount, blinder, timestamp). This binds the commitment to a specific token type, preventing a commitment for 100 swords from being passed off as 100 potions.
The circuit enforces that all inputs and outputs in a transfer use the same tokenId, so cross-type transfers are impossible by construction.
Why Single tokenId per Transfer?
Each transfer() call operates on exactly one tokenId. This keeps the circuit simple (no cross-token amount conservation needed) and the verifier efficient. Transferring multiple token types requires multiple calls — which is acceptable since multi-type atomic transfers are rare in practice.
Batch minting across multiple tokenIds is supported via mintBatch() since minting doesn’t require ZK proofs for amount conservation (the minted amount is public).
Why Not Hide tokenId?
Hiding tokenId would require:
- A Merkle tree of valid tokenIds (to prove the tokenId exists without revealing it)
- Cross-type conservation proofs (much more complex circuits)
- Hidden supply tracking (incompatible with most compliance requirements)
The benefit is minimal for most use cases (game items, loyalty points, tickets) where the token type being transferred is not sensitive. Keeping tokenId public dramatically simplifies the protocol.
Why SPENT_MARKER Per (address, tokenId)?
A single address can hold different tokenIds. Spending one tokenId doesn’t affect others. The SPENT marker is per (address, tokenId) pair, allowing independent lifecycle management.
Performance
Estimated gas (with mock verifiers — add ~200K per Groth16 verification for production):
| Operation | Gas | Notes |
|---|---|---|
| mint (single) | ~320–360K | Single tokenId |
| mintBatch (5 tokenIds) | ~1.4–1.6M | 5 tokenIds in one tx |
| burn (with change) | ~350–390K | Partial burn |
| transfer (1→2) | ~370–410K | 1 input, 2 outputs |
| transfer (2→2) | ~420–460K | Standard transfer |
| consolidate (5→1) | ~500–550K | Merge 5 VOSAs |
| createPolicy | ~410–460K | Per tokenId |
| revokePolicy | ~30–35K |
Circuit constraint counts are ~15% higher than the ERC-20 equivalents due to the additional tokenId field in commitment computation (Poseidon with 4 inputs vs 3).
Backwards Compatibility
This standard does NOT implement the ERC-1155 interface (balanceOf(address,uint256), safeTransferFrom, etc.). It is a separate standard for privacy-preserving multi-tokens. The uri() function is retained for metadata compatibility.
Security Considerations
Cross-Token-Type Attack Prevention
The circuit enforces that all commitments in a transfer use the same tokenId. This is verified inside the ZK proof — there is no way to mix token types without invalidating the proof.
Trusted Setup
Same as Private ERC-20: Groth16 requires a trusted setup ceremony per circuit variant.
Front-Running Protection
EIP-712 signatures bind to tokenId and all mutable parameters. A signature for tokenId=42 cannot be replayed for tokenId=7.
Double-Spending
Same O(1) SPENT_MARKER mechanism as Private ERC-20, but indexed by (address, tokenId).
Amount Security
Range proofs enforce 0 ≤ amount < 2^96. Amount conservation is enforced per-tokenId in the ZK circuit.
Batch Mint Atomicity
mintBatch() is atomic — all tokenIds mint or none do. Each tokenId gets its own commitment and ZK proof.
Reference Implementation
nft-native/
├── contracts/
│ ├── src/
│ │ ├── PrivateERC1155.sol # Main contract (~1160 lines)
│ │ ├── libraries/
│ │ │ └── PublicInputBuilder.sol # ZK public input construction helpers
│ │ ├── interfaces/
│ │ │ ├── IGroth16Verifier.sol
│ │ │ └── IPoseidon.sol
│ │ └── mocks/
│ │ ├── MockVerifier.sol
│ │ └── MockPoseidon.sol
│ └── test/
│ └── PrivateERC1155.test.ts
├── circuits/
│ ├── src/
│ │ ├── commitment1155.circom # Poseidon(tokenId, amount, blinder, ts)
│ │ ├── amount1155.circom # Mint/burn circuits (0→1, 1→0, 1→1)
│ │ ├── transfer1155.circom # Transfer circuits (N→M)
│ │ └── amount_0_1_main.circom # Mint circuit entry point
│ └── lib/
│ ├── poseidon.circom # circomlib Poseidon
│ └── comparators.circom # circomlib comparators + bitify
└── circuits/scripts/
├── compile.sh # Circuit compilation
└── test.sh # Witness + proof generation test
Questions for Discussion
- Single tokenId per transfer: Is this acceptable, or should the standard support atomic multi-tokenId transfers (at the cost of much more complex circuits)?
- Batch operations: Should
transferBatch()(multiple tokenIds in one call) be part of the standard, or is sequentialtransfer()sufficient? - tokenId visibility: Are there use cases where hiding the token type matters enough to justify the circuit complexity?
- Metadata privacy: The current
uri()is public. Should there be a mechanism for encrypted per-holder metadata? - Interop with ERC-1155: Should there be a CompatibleERC1155 variant (like CompatibleERC20) with public balances + private mode switching?
Copyright
Copyright and related rights waived via CC0.