A canonical validity layer for private ETH and compatible ERC-20 transfers via a system contract and a split-proof architecture.
Abstract
This EIP introduces private ETH and ERC-20 transfers via a shielded-pool system contract. The pool does not mandate a single spend-authorization method: each user registers their own (e.g., ECDSA signature, passkey). Beyond installing the shielded-pool system contract at fork activation, this EIP introduces no new precompile, opcode, transaction type, or other change to the Ethereum protocol.
Motivation
Sending assets publicly on Ethereum is straightforward. A user chooses ETH or a token, specifies a recipient using an Ethereum address or ENS name, and clicks send in an Ethereum wallet. Recipients, wallets, and applications already know how to interpret that transfer because they rely on the same shared standards.
Private transfers have no analogous shared default today, even though many ordinary financial activities require privacy. Payroll, treasury management, donations, and similar activities typically require that the sender, recipient, or amount not be globally visible. Without a shared private transfer layer, Ethereum cannot serve these use cases directly, so they are pushed toward traditional financial systems or other blockchains.
If private transfers are valuable, why has the market not produced a widely adopted default on Ethereum? Because a private transfer application cannot compete on product quality alone. Its effectiveness also depends on how many users and how much value share the same pool. A small pool offers weak privacy even for a superior product, while a large pool can remain attractive even when competing products are better. That means app-layer teams cannot focus only on wallet UX, authentication, compliance, or proof systems. They must also persuade users to deposit into their pool, which is difficult when the pool is not already large.
But growing the pool is only part of the problem. App-layer teams also have to decide how the pool changes over time. If the pool is upgradeable, the parties with the power to change it could compromise user funds. Immutable pools avoid that risk, but they cannot adapt as proof systems weaken or cryptographic assumptions change. Neither is a good foundation for common privacy infrastructure.
The Ethereum protocol should break this impasse by providing a shared privacy layer. This EIP does that by defining a protocol-managed private transfer system, updated only through Ethereum's hard-fork process, that provides a common pool for ETH and compatible ERC-20 tokens. Users still identify each other by address or ENS, while notes themselves bind to hidden owner identifiers fetched from the registry for that address. Applications can then build on that base without each having to bootstrap, govern, and defend their own pool.
Scope
This EIP specifies the on-chain component: the pool contract, proof system, and registries. End-to-end transaction privacy still requires complementary infrastructure (note delivery, mempool encryption, network-layer anonymity, wallet integration) that is out of scope. Note delivery in particular is left to wallet coordination or companion standards; see Section 13.
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.
1. Overview
This EIP defines:
- A system contract deployed at a protocol-defined address, holding all shielded pool state (note-commitment tree, nullifier set, intent replay ID set, user registry, and auth policy registry) with no proxy, no admin function, and no on-chain upgrade mechanism.
- A proof-free deposit path that inserts one note for a hidden owner-side commitment.
- A split-proof architecture for note spending: a fork-managed Groth16 BN254 pool proof verified by the system contract, plus an auth proof verified by a user-registered auth verifier contract via
staticcall. - A private auth-policy registry composed of an append-only registration tree and a sparse revocation tree, binding users to verifier-defined auth data without publishing the user-to-verifier mapping.
These components are presented as a single EIP because they share state and form a single deployment unit.
2. Terminology
- Note: A shielded UTXO-like object represented on-chain by a final
noteCommitment. - Note body commitment: The semantic note commitment that binds owner-side and value-side note contents before insertion-specific uniqueness is added.
- Note commitment: The final note-tree leaf commitment. It binds
noteBodyCommitmentplus the assigned note-tree leaf index. - Nullifier: The public spent-note marker for one real input note.
- Phantom nullifier: The public spent-input marker for one phantom input slot.
- Pool circuit: The hard-fork-managed circuit that enforces protocol invariants for note spending: value conservation, nullifiers, Merkle membership, deterministic note-secret derivation for ordinary outputs, blinded auth commitment recomputation, transaction-intent-digest recomputation, and auth policy checks. The system contract verifies its Groth16 proof against the embedded verification key.
- Auth circuit: A permissionless circuit that handles authentication and intent parsing. Outputs
[blindedAuthCommitment, transactionIntentDigest]. Each auth circuit has a correspondingauthVerifierSolidity contract that verifies its proofs. See Sections 9.1 and 12. - authVerifier: The Solidity contract address that verifies auth proofs for one specific auth circuit. Each address may register multiple auth policies, one per
authVerifier. See Sections 6.4 and 12. - Auth policy: A
(user, authVerifier, authDataCommitment)binding recorded as one leaf of the auth-policy registration tree and referenced by itsleafPosition. See Sections 5.2 and 6.4. - authDataCommitment: The opaque commitment a user registers in the auth-policy registration tree. The auth circuit derives this value from the user's auth data.
- registrationBlinder: Per-registration user secret hashed into
policyCommitmentto keep the(user, authVerifier)mapping unrecoverable from public tree state. Derivable from the wallet seed; stays witness-only. - policyCommitment: Opaque
uint256Poseidon2 digest submitted toregisterAuthPolicy(Section 5.3). The contract does not decompose it. - leafPosition: Sequential position assigned by the contract on
registerAuthPolicy, witnessed by the pool proof to prove registration membership and revocation non-membership. - blindedAuthCommitment:
poseidon(BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor). Public auth-proof output and pool-proof input; per-tx blinding hides the registered auth data. - blindingFactor: Fresh per-tx random value used as preimage input to
blindedAuthCommitment. Bound by the auth proof's authorization relation but excluded fromtransactionIntentDigest. - Transaction intent digest: The canonical digest of the contemplated private-note spend. It includes the transaction fields bound by the auth relation, the chosen
authVerifier, and a randomnonce. The auth circuit authenticates this digest from the authorization-bound intent fields and any companion-standard constants; the pool circuit recomputes the same formula from witnesses, public inputs, and mode-derived values. - Intent replay ID: The transaction-level replay identifier consumed on use. It shares the replay domain inputs across all outputs from one
transactcall. See Section 9.8. - Phantom input: A dummy input slot used to maintain constant arity (2-input circuit) while spending only one real note. An observer MUST NOT be able to distinguish phantom from real inputs.
- Dummy output: A dummy output slot used to maintain constant output count (3 outputs) while producing fewer real notes.
- User registry: A Merkleized mapping from
addressto(ownerNullifierKeyHash, noteSecretSeedHash). Leaf format:poseidon(USER_REGISTRY_LEAF_DOMAIN, uint160(user), ownerNullifierKeyHash, noteSecretSeedHash)(Section 3.4). - Owner nullifier key: The note owner's non-rotatable hidden note-ownership key. It is hashed into
ownerNullifierKeyHash. - ownerNullifierKeyHash: The hidden account identifier used inside notes and the user registry.
ownerNullifierKeyHash = poseidon(OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey). - noteSecretSeed: A rotatable secret committed in the user registry as
noteSecretSeedHash = poseidon(NOTE_SECRET_SEED_DOMAIN, noteSecretSeed). Used to derive future note secrets the wallet controls. - noteSecret: The per-note hidden blinder. In ordinary
transactoutputs it is deterministically derived fromnoteSecretSeed,intentReplayId, andoutputIndex. In deposits it is chosen or derived by the depositing wallet and communicated to the recipient throughoutputNoteDataor out-of-band coordination. - ownerCommitment:
poseidon(OWNER_COMMITMENT_DOMAIN, ownerNullifierKeyHash, noteSecret). The owner-side note commitment. HidesownerNullifierKeyHashandnoteSecretfrom on-chain observers while letting the contract incorporate them into the finalnoteCommitment. - leafIndex: The final note-tree leaf index assigned by the contract when the note is inserted.
- Output note data: Opaque per-output bytes emitted by the contract for wallet/app-layer note delivery. The base protocol does not validate or interpret these bytes. Delivery may be coordinated out of band or standardized by a companion ERC; see Section 13.
- Output binding:
poseidon(OUTPUT_BINDING_DOMAIN, noteBodyCommitment, outputNoteDataHash). This binds one emitted semantic note commitment to one output-note-data hash for authorization-bound finalized-output locking. - Execution constraints: The private authorization-bound fields
executionConstraintsFlagsandlockedOutputBinding0/1/2. They optionally bind finalized output slots. - authorizingAddress: The private address reused across auth policy lookup, user-registry lookup, and output ownership binding inside
transact. - recipientAddress: A private witness in the transaction intent digest. In transfers, it identifies the registry entry whose
ownerNullifierKeyHashmust be used for output slot 0. In withdrawals, it is the private intent-level mirror of the public withdrawal recipient. - feeRecipientAddress: A private witness in the transaction intent digest. The optional designated recipient of the private fee note in output slot 2.
0means the authorization leaves slot-2 recipient selection to the prover, subject to the slot-2 fee rules. - feeNoteRecipientAddress: A private pool-circuit witness for the actual recipient address of output slot 2 when it is real. If
feeRecipientAddress != 0, the circuit MUST enforcefeeNoteRecipientAddress == feeRecipientAddress. IffeeRecipientAddress == 0andfeeAmount > 0, the prover choosesfeeNoteRecipientAddressat proof generation time. - feeAmount: A private witness in the transaction intent digest. The optional private fee paid through output slot 2.
0means no fee. - nonce: A private authorization-bound random
uint256value used for replay protection and transaction-intent-digest privacy intransact. - executionConstraintsFlags: A private authorization-bound bitmask selecting which finalized-output slots are locked.
- lockedOutputBinding0/1/2: Private authorization-bound
uint256values that optionally lockoutputBinding0/1/2for slots 0, 1, and 2. - publicRecipientAddress: Public input. The withdrawal destination address. Zero for private transfers.
- publicTokenAddress: Public input. The withdrawn token address. Zero for private transfers.
- publicAmountOut: Public input. The withdrawn amount. Zero for private transfers.
3. Parameters and Constants
3.1 Domain Separators
All Poseidon hashes that require domain separation MUST include a distinct domain tag (field element). Each domain tag is derived as:
DOMAIN = uint256(keccak256("eip-8182.<context_name>")) mod p
where p is the BN254 scalar field order (the field over which the pool SNARK circuit and Poseidon operate) and <context_name> is the string identifier listed below. This derivation is deterministic and fixes all domain tags.
The following domain tags are defined by this EIP:
| Constant | Context string | Usage |
|---|---|---|
OWNER_NULLIFIER_KEY_HASH_DOMAIN |
owner_nullifier_key_hash |
Owner nullifier key hashing |
OWNER_COMMITMENT_DOMAIN |
owner_commitment |
Owner-side note commitment |
NOTE_BODY_COMMITMENT_DOMAIN |
note_body_commitment |
Semantic note commitment |
NOTE_COMMITMENT_DOMAIN |
note_commitment |
Final inserted note commitment |
NULLIFIER_DOMAIN |
nullifier |
Real note nullifiers |
PHANTOM_NULLIFIER_DOMAIN |
phantom_nullifier |
Phantom nullifiers |
INTENT_REPLAY_ID_DOMAIN |
intent_replay_id |
Intent replay IDs |
TRANSACT_NOTE_SECRET_DOMAIN |
transact_note_secret |
Ordinary output note-secret derivation |
NOTE_SECRET_SEED_DOMAIN |
note_secret_seed |
Note secret seed hashing |
TRANSACTION_INTENT_DIGEST_DOMAIN |
transaction_intent_digest |
Transaction intent digests |
OUTPUT_BINDING_DOMAIN |
output_binding |
Per-slot output bindings |
AUTH_POLICY_DOMAIN |
auth_policy |
Auth-policy registration tree leaves |
POLICY_COMMITMENT_DOMAIN |
policy_commitment |
Wallet-submitted auth-policy commitment |
BLINDED_AUTH_COMMITMENT_DOMAIN |
blinded_auth_commitment |
Blinded auth commitments |
USER_REGISTRY_LEAF_DOMAIN |
user_registry_leaf |
User registry leaves |
All values are deterministically computable from the derivation formula above and MUST be < p.
Internal Merkle-tree nodes use poseidon(left, right); the Section 3.3 length-tagged sponge separates these 2-input hashes from domain-tagged application hashes.
3.2 Fixed Constants
MAX_INTENT_LIFETIME = 86400— maximum allowed forward offset fromblock.timestamptovalidUntilSeconds, in seconds (24 hours), checked at submission time. This means proofs are accepted only during the final 24 hours before expiry; it does not measure authorization age from signing time. Root-history windows independently bound proof freshness.NOTE_COMMITMENT_ROOT_HISTORY_SIZE = 500— consensus-critical, fixed by spec.AUTH_POLICY_REGISTRATION_ROOT_HISTORY_SIZE = 500— circular-buffer size for the append-only auth-policy registration tree. Consensus-critical, fixed by spec.USER_REGISTRY_ROOT_HISTORY_BLOCKS = 500— consensus-critical, fixed by spec.AUTH_POLICY_ROOT_HISTORY_BLOCKS = 64— applies to the sparse auth-policy revocation tree. Consensus-critical, fixed by spec.DUMMY_OWNER_NULLIFIER_KEY_HASH = poseidon(OWNER_NULLIFIER_KEY_HASH_DOMAIN, 0xdead)— used for dummy output slots. The circuit enforcesamount == 0for dummy outputs, preventing value extraction regardless of preimage knowledge.TRANSFER_OP = 0— operation kind for private transfers.WITHDRAWAL_OP = 1— operation kind for withdrawals.LOCK_OUTPUT_BINDING_0 = 1 << 0— lock output slot 0's finalized output binding.LOCK_OUTPUT_BINDING_1 = 1 << 1— lock output slot 1's finalized output binding.LOCK_OUTPUT_BINDING_2 = 1 << 2— lock output slot 2's finalized output binding.
3.3 Poseidon Hash Construction
This EIP uses Poseidon2 over the BN254 scalar field p (defined in Section 3.1) with the following parameters:
- State width:
t = 4(capacity = 1, rate = 3) - S-box:
x^5(α = 5) - Full rounds:
R_F = 8 - Partial rounds:
R_P = 56 - External matrix, internal diagonal, and round constants: exactly the constants in the Poseidon2 parameter asset. The corresponding normative vectors are in the Poseidon2 vector asset.
The single hash function used throughout this EIP is:
poseidon(x_1, ..., x_N) = Poseidon2_sponge(x_1, ..., x_N)
Poseidon2_sponge is defined as follows. Initialize the 4-element state to [0, 0, 0, N << 64], where N is the number of inputs. If N = 0, apply one Poseidon2 permutation to this initial state and return state element 0. Otherwise, partition the inputs into ⌈N/3⌉ chunks of 3 elements each, zero-padding the final chunk with 0 when N mod 3 ≠ 0. For each chunk [c_0, c_1, c_2] in order, compute state[j] ← (state[j] + c_j) mod p for j ∈ {0, 1, 2}, then apply one Poseidon2 permutation to the state. After all chunks are processed, return state element 0.
There is no separate hash_2 primitive. Two-input hashes — including Merkle tree internal nodes — invoke poseidon(a, b), which by the algorithm above produces state element 0 after one permutation over [a, b, 0, 2 << 64]. Domain-separated hashes prepend their domain tag as the first input, e.g. poseidon(OUTPUT_BINDING_DOMAIN, noteBodyCommitment, outputNoteDataHash) is a 3-input sponge call. A summary of hash contexts is in Section 11.
Because the capacity position encodes N << 64, poseidon(a, b) is not equivalent to the bare-permutation form that initializes capacity to 0 (as used by some Poseidon2 Merkle tree libraries). Implementations MUST use the length-tagged sponge form defined here to match this EIP's hash outputs and tree roots.
3.4 Merkle Tree Constructions
Unless otherwise stated, all Merkle trees in this EIP hash internal nodes as poseidon(left, right) per Section 3.3. The length-tagged sponge initializes a 2-input node hash to a distinct sponge state from any domain-separated application hash with arity ≥ 3, so the two cannot collide. Empty internal nodes follow the ladder EMPTY[i + 1] = poseidon(EMPTY[i], EMPTY[i]) with EMPTY[0] = 0 (named per tree, e.g. EMPTY_NOTE_COMMITMENT).
Note commitment tree. Depth-32 append-only. Leaf indices are uint32 values in [0, 2^32 - 1], assigned sequentially from 0. Empty leaf is 0. A membership proof is an ordered list of 32 sibling nodes from leaf level upward. At height h in [0, 31], bit h of leafIndex (least-significant bit at height 0) selects left (0) or right (1) child when computing poseidon(left, right).
User registry tree. Depth-160 sparse, keyed by uint160(user) interpreted MSB-first (at depth d = 0 is MSB; bit 0 selects left, bit 1 selects right). Leaf value: poseidon(USER_REGISTRY_LEAF_DOMAIN, uint160(user), ownerNullifierKeyHash, noteSecretSeedHash). Empty leaf is 0.
Auth-policy registration tree. Depth-32 append-only, structurally identical to the note commitment tree (same LSB-first bit convention on leafPosition). Leaves assigned sequentially on registerAuthPolicy. Leaf value: poseidon(AUTH_POLICY_DOMAIN, uint160(user), policyCommitment), where policyCommitment is defined in Section 5.3. Empty leaf is 0.
Auth-policy revocation tree. Depth-32 sparse, keyed by leafPosition (LSB-first, matching the registration tree so the pool circuit reuses one leafPosition bit decomposition). Leaf value is 1 for a revoked registration and 0 otherwise. Empty leaf is 0.
3.5 Public-Input Field-Element Encoding
Each public input is a uint256 interpreted as a BN254 scalar field element and MUST satisfy x < p (Section 3.1). This is automatic for Poseidon2 outputs, addresses (< 2^160), bounded amounts (< 2^248), and uint32 fields. outputNoteDataHash0/1/2 are explicitly reduced mod p per Section 9.7. The system contract rejects any non-canonical public input; otherwise x and x + p would verify identically but map to different uint256 storage keys, enabling nullifier reuse or intent replay.
4. Architecture
This EIP uses a split-proof architecture that splits note spending into two independently-verified proofs with different trust properties.
Deposits are contract-native. Public deposits create notes directly through the pool contract. No proof is required for deposit insertion. The split-proof architecture below applies to transact, which spends existing private notes.
Pool proof (Groth16 BN254 SNARK, hard-fork-managed). There is exactly one pool circuit; its relation can only change via hard fork. It enforces all protocol invariants for transact: value conservation, nullifier derivation, Merkle membership, deterministic note-secret derivation for ordinary outputs, user-registry checks, auth-policy checks, blinded-auth-commitment recomputation, transaction-intent-digest recomputation, and token consistency. The system contract verifies this proof using the embedded verification key (Section 5.4.1 step 9). The pool circuit is the security boundary — a bug here can compromise all funds in the pool.
Auth proof (permissionless). Anyone can write and deploy an auth circuit and a corresponding authVerifier Solidity contract. It handles authentication — verifying the user's credential or policy — and intent parsing — computing the transaction intent digest over transaction fields, the chosen authVerifier, and any authorization-selected execution constraints. It outputs two public values: [blindedAuthCommitment, transactionIntentDigest]. The system contract dispatches the auth proof to the user-selected authVerifier via staticcall (Section 12).
Both proofs are verified in one transact call (pool within the system contract, auth via staticcall to authVerifier); both share [blindedAuthCommitment, transactionIntentDigest] taken from the pool's public inputs. Section 9.1 is the normative interface.
| Responsibility | Where enforced | Fork required? |
|---|---|---|
| Value conservation, nullifier derivation, Merkle membership | Pool | Yes |
| Deterministic ordinary note-secret derivation | Pool | Yes |
| Auth-policy registration membership check | Pool | Yes |
| Auth-policy revocation non-membership check | Pool | Yes |
| User registry checks | Pool | Yes |
| Intent replay ID, transaction-intent-digest, blinded-auth-commitment recomputation | Pool | Yes |
| Pool proof verification and auth verifier dispatch | System contract | Yes |
| Credential or policy authorization, intent parsing | Auth | No |
| Auth data commitment derivation, blinded auth commitment construction | Auth | No |
A bug in the pool circuit risks every note; a bug in an auth circuit risks only users registered at its authVerifier.
Output note delivery. outputNoteData0, outputNoteData1, and outputNoteData2 are hash-bound to the proof via outputNoteDataHash0, outputNoteDataHash1, and outputNoteDataHash2 (public inputs). An authorization MAY additionally lock a slot's emitted semantic note commitment to its payload hash through outputBinding. Neither the auth circuit nor the pool circuit enforces any encryption scheme or delivery format; the bytes are protocol-opaque. See Section 13.
5. System Contract
5.1 Deployment and Upgrade Model
The shielded pool is deployed as a system contract at SHIELDED_POOL_ADDRESS = 0x0000000000000000000000000000000000081820.
At the activation fork, clients MUST install a system-contract account at SHIELDED_POOL_ADDRESS implementing this specification. The exact bytecode is incorporated into client releases at activation time.
- The code at
SHIELDED_POOL_ADDRESScan only be replaced by a subsequent hard fork that sets new code as part of its state transition rules. - There is no proxy, no admin function, and no on-chain upgrade mechanism.
- Storage persists across fork-initiated code replacements.
The verification-key byte layout, public-input layout, and pairing equation are normative in Section 5.5. The system contract embeds the verification key in its bytecode and verifies pool proofs against that embedded key. The verification key is fixed by a one-time multi-party trusted-setup ceremony for the pool circuit, the same pattern used for KZG in EIP-4844; the bytecode is finalized when that ceremony completes, which is why this EIP does not pin a specific bytecode.
5.2 State
The pool MUST maintain:
- Note commitment tree — append-only Poseidon Merkle tree (depth: 32, ~4B leaves). Empty leaf = 0. Holds multi-asset notes (
tokenAddressis inside the note commitment). The contract MUST revert ifnextLeafIndex + 3 > 2^32before atransactinsertion or ifnextLeafIndex + 1 > 2^32before adepositinsertion. - Note commitment root history — circular buffer (size:
NOTE_COMMITMENT_ROOT_HISTORY_SIZE, consensus-critical). On eachtransactand eachdeposit, the contract MUST push the pre-insertion note-commitment root into this buffer. The contract accepts the current root or any historical root still in the buffer. - Nullifier set —
mapping(uint256 => bool). - Intent replay ID set —
mapping(uint256 => bool). - User registry — depth-160 sparse Poseidon Merkle tree, with block-based root history (window:
USER_REGISTRY_ROOT_HISTORY_BLOCKS). - Auth-policy registration tree — depth-32 append-only Poseidon Merkle tree, with
nextLeafIndextracked. Empty leaf = 0. The contract MUST revert ifnextLeafIndex + 1 > 2^32before a registration insertion. - Auth-policy registration root history — circular buffer (size:
AUTH_POLICY_REGISTRATION_ROOT_HISTORY_SIZE, consensus-critical). On eachregisterAuthPolicy, the contract MUST push the pre-insertion registration-tree root into this buffer. The contract accepts the current root or any historical root still in the buffer. - Auth-policy revocation tree — depth-32 sparse Poseidon Merkle tree keyed directly by
leafPosition, with block-based root history (window:AUTH_POLICY_ROOT_HISTORY_BLOCKS). - Auth-policy owner index —
mapping(uint256 leafPosition => address owner)written at registration. Used to gatederegisterAuthPolicyso only the registering address can revoke a given leaf.address(0)denotes an unused position. - ownerNullifierKeyHash index —
mapping(uint256 ownerNullifierKeyHash => address user)mapping each registeredownerNullifierKeyHashto the address that owns it. Used to enforce globalownerNullifierKeyHashuniqueness on registration.address(0)denotes an unregisteredownerNullifierKeyHash.
5.2.1 Block-Based Registry Root Histories
The user registry and auth-policy revocation tree use block-based root histories. The auth-policy registration tree uses the same circular-buffer history pattern as the note commitment tree. For a registry with window W, the contract maintains a ring buffer of W + 1 (root, blockNumber) pairs. The extra slot prevents a mutation in block N + W from overwriting a root that is still within the acceptance window.
On the first mutation to a registry in block N, the contract MUST snapshot the root accepted at the start of block N into the ring buffer at position N mod (W + 1) with blockNumber = N. Subsequent mutations to the same registry in block N update the current root but MUST NOT create additional history entries.
A candidate root r is accepted iff there exists a stored pair (storedRoot, storedBlockNumber) such that storedRoot == r and block.number - storedBlockNumber <= W. The current root is always accepted. For the user registry and auth-policy revocation tree, r = 0 is never accepted, regardless of history contents.
Because only the start-of-block root is preserved, intermediate same-block roots are not retained once later same-block mutations occur. Wallets and provers SHOULD avoid depending on same-block user-registry or auth-policy-revocation changes unless transaction ordering is controlled; the safer default is to wait at least one subsequent block before proving against the new root.
5.3 Contract Interface
The pool MUST expose the following functions.
Private-note spend path
struct PublicInputs {
uint256 noteCommitmentRoot;
uint256 nullifier0;
uint256 nullifier1;
uint256 noteBodyCommitment0;
uint256 noteBodyCommitment1;
uint256 noteBodyCommitment2;
uint256 publicAmountOut;
uint256 publicRecipientAddress;
uint256 publicTokenAddress;
uint256 intentReplayId;
uint256 registryRoot;
uint256 validUntilSeconds;
uint256 executionChainId;
uint256 authPolicyRegistrationRoot;
uint256 authPolicyRevocationRoot;
uint256 outputNoteDataHash0;
uint256 outputNoteDataHash1;
uint256 outputNoteDataHash2;
uint256 authVerifier;
uint256 blindedAuthCommitment;
uint256 transactionIntentDigest;
}
function transact(
bytes calldata poolProof,
bytes calldata authProof,
PublicInputs calldata publicInputs,
bytes calldata outputNoteData0,
bytes calldata outputNoteData1,
bytes calldata outputNoteData2
) external
Public deposit path
function deposit(
address token,
uint256 amount,
uint256 ownerCommitment,
bytes calldata outputNoteData
) external payable
Read methods
function getCurrentRoots()
external
view
returns (
uint256 noteCommitmentRoot,
uint256 registryRoot,
uint256 authPolicyRegistrationRoot,
uint256 authPolicyRevocationRoot
)
function getUserRegistryEntry(
address user
) external view returns (
bool registered,
uint256 ownerNullifierKeyHash,
uint256 noteSecretSeedHash
)
function isAcceptedNoteCommitmentRoot(
uint256 root
) external view returns (bool)
function isAcceptedUserRegistryRoot(
uint256 root
) external view returns (bool)
function isAcceptedAuthPolicyRegistrationRoot(
uint256 root
) external view returns (bool)
function isAcceptedAuthPolicyRevocationRoot(
uint256 root
) external view returns (bool)
function isNullifierSpent(
uint256 nullifier
) external view returns (bool)
function isIntentReplayIdUsed(
uint256 intentReplayId
) external view returns (bool)
function isRevokedAuthPolicy(
uint256 leafPosition
) external view returns (bool)
getCurrentRoots returns the current note-commitment root, current user-registry root, current auth-policy registration-tree root, and current auth-policy revocation-tree root accepted by the contract.
getUserRegistryEntry returns the current user-registry entry for user, or (false, 0, 0) if the address is not registered.
isAcceptedNoteCommitmentRoot, isAcceptedUserRegistryRoot, isAcceptedAuthPolicyRegistrationRoot, and isAcceptedAuthPolicyRevocationRoot return whether the supplied root would currently pass the same acceptance rule enforced by transact. isAcceptedUserRegistryRoot(0), isAcceptedAuthPolicyRegistrationRoot(0), and isAcceptedAuthPolicyRevocationRoot(0) MUST return false.
isRevokedAuthPolicy returns whether the revocation-tree leaf at leafPosition is 1, and MUST revert if leafPosition >= 2^32 (matching deregisterAuthPolicy's range rule).
isNullifierSpent returns whether the supplied nullifier has already been marked spent. isIntentReplayIdUsed returns whether the supplied intent replay ID has already been consumed.
These read methods are the canonical online read path for current state and status checks used by wallets, provers, relayers, and sponsors. Note-commitment-tree sync, note discovery, and witness construction remain off-chain event/indexer workflows; the spec MUST NOT require replay from genesis as the only standard read path.
User registration
function registerUser(
uint256 ownerNullifierKeyHash,
uint256 noteSecretSeedHash
) external
function rotateNoteSecretSeed(
uint256 newNoteSecretSeedHash
) external
Auth policy registration
function registerAuthPolicy(
uint256 policyCommitment
) external returns (uint256 leafPosition)
function deregisterAuthPolicy(
uint256 leafPosition
) external
registerAuthPolicy is called by msg.sender to append an auth-policy leaf. The caller computes policyCommitment = poseidon(POLICY_COMMITMENT_DOMAIN, uint160(authVerifier), authDataCommitment, registrationBlinder) off-chain and submits only that single uint256 digest; the contract never sees authVerifier, authDataCommitment, or registrationBlinder. The caller MUST already have a user-registry entry. A single address may register as many auth policies as it wants — each registration appends a new leaf at a new leafPosition.
- MUST reject
policyCommitment == 0orpolicyCommitment >= p(Section 3.5). - Computes
leafValue = poseidon(AUTH_POLICY_DOMAIN, uint160(msg.sender), policyCommitment). This on-chain hash binds the registration'suserfield tomsg.senderby construction; a caller cannot forge a leaf claiming another address as user. - MUST revert if the computed
leafValue == 0(defensive; a zero leaf is reserved for empty positions). - Pushes the pre-insertion registration-tree root into the auth-policy registration root-history circular buffer.
- Appends
leafValueat the nextleafPositionin the auth-policy registration tree. - Writes
authPolicyOwner[leafPosition] = msg.senderatomically with the tree insertion. - Returns
leafPosition.
deregisterAuthPolicy is called by the original registrant to revoke a previously registered auth policy. The caller supplies only leafPosition. The caller MUST already have a user-registry entry.
- MUST revert if
authPolicyOwner[leafPosition] != msg.sender. This restricts revocation to the original registrant and removes the need for blinder-based authentication, soregistrationBlindernever leaves the wallet. - MUST revert if
leafPosition >= 2^32. - MUST revert if the revocation-tree leaf at
leafPositionis already1(already revoked). - Writes
1at the revocation-tree leaf keyed byleafPositionand maintains the block-based revocation-tree root history.
A revocation does not remove the original leaf from the registration tree, but any proof against that leaf fails its revocation-tree non-membership check once the stale revocation-root window expires (Section 9.6).
The contract MUST emit:
event ShieldedPoolTransact(
uint256 indexed nullifier0,
uint256 indexed nullifier1,
uint256 indexed intentReplayId,
address authVerifier,
uint256 noteCommitment0,
uint256 noteCommitment1,
uint256 noteCommitment2,
uint256 leafIndex0,
uint256 postInsertionCommitmentRoot,
bytes outputNoteData0,
bytes outputNoteData1,
bytes outputNoteData2
);
event ShieldedPoolDeposit(
address indexed depositor,
uint256 noteCommitment,
uint256 leafIndex,
uint256 amount,
uint256 tokenAddress,
uint256 postInsertionCommitmentRoot,
bytes outputNoteData
);
event UserRegistered(
address indexed user,
uint256 ownerNullifierKeyHash,
uint256 noteSecretSeedHash
);
event NoteSecretSeedRotated(
address indexed user,
uint256 noteSecretSeedHash
);
event AuthPolicyRegistered(
address indexed user,
uint256 leafPosition,
uint256 leafValue,
uint256 postInsertionRegistrationRoot
);
event AuthPolicyDeregistered(
address indexed user,
uint256 leafPosition
);
5.4 Execution
transact and deposit MUST each be non-reentrant.
5.4.1 transact
On each transact call, the pool MUST execute the following steps:
Verify execution chain ID. Require
executionChainId == block.chainid.Enforce intent expiry.
- Require
validUntilSeconds > 0. - Require
block.timestamp <= validUntilSeconds. - Require
validUntilSeconds <= block.timestamp + MAX_INTENT_LIFETIME.
This is a submission-window bound, not a measure of time since signing.
- Require
Check note-commitment root. Require
noteCommitmentRootequals the current note-commitment root or is in the note-commitment root history.Check registry root. Require
registryRootequals the current user-registry root or is in the user-registry root history.registryRootMUST be nonzero.Check auth-policy registration root. Require
authPolicyRegistrationRootequals the current auth-policy registration root or is in the registration root history.authPolicyRegistrationRootMUST be nonzero.Check auth-policy revocation root. Require
authPolicyRevocationRootequals the current auth-policy revocation root or is in the revocation root history.authPolicyRevocationRootMUST be nonzero.Enforce nullifier uniqueness. Require
nullifier0 != nullifier1. The contract MUST NOT attempt to distinguish phantom nullifiers from real ones.Enforce public input ranges.
- Require
publicAmountOut < 2^248. Larger values could overflow the balance equation inside the circuit (Section 7.1). - Require
publicRecipientAddress < 2^160,publicTokenAddress < 2^160, andauthVerifier < 2^160. Values>= 2^160alias when interpreted as EVM addresses. - Require
validUntilSeconds < 2^32. - Require
executionChainId < 2^32. - Require
authVerifier != 0.
- Require
Verify the pool proof. Verify
poolProofagainstpublicInputsusing the embedded Groth16 BN254 verification key per Section 5.5. Revert if any failure mode in Section 5.5 is hit.Verify the auth proof via the auth verifier. Construct
authPublicInputs = abi.encode(blindedAuthCommitment, transactionIntentDigest). InvokeIAuthVerifier(address(uint160(authVerifier))).verifyAuth(authPublicInputs, authProof)viastaticcall(Section 12). MUST revert if the staticcall reverts, returns non-32 bytes, or returnsfalse.Mark nullifiers spent. Require both nullifiers are unspent; then mark them spent.
Mark intent replay ID used. Require
intentReplayIdis unused; then mark it used.Verify output note data hashes. For each
i ∈ {0, 1, 2}, require(uint256(keccak256(outputNoteData_i)) mod p) == outputNoteDataHash_i(Section 9.7), binding the payloads to the proof. The contract MUST NOT otherwise interpret or validate payload contents.Execute public asset movement.
transactis non-payable; anymsg.value > 0reverts on entry. Exactly one of the following two branches MUST match:- Withdrawal (
publicAmountOut > 0)- Require
publicRecipientAddress != 0. - If
publicTokenAddress == 0(ETH): perform a low-levelCALLtoaddress(uint160(publicRecipientAddress))with valuepublicAmountOut, empty calldata, and all remaining gas; require success. - If
publicTokenAddress != 0(ERC-20): executetransfer(publicRecipientAddress, publicAmountOut)and require success.
- Require
- Transfer (
publicAmountOut == 0)- Require
publicRecipientAddress == 0. - Require
publicTokenAddress == 0.
- Require
- Withdrawal (
Assign leaf indices and insert outputs.
Require
nextLeafIndex + 3 <= 2^32.Let
leafIndex0 = nextLeafIndex.Compute:
noteCommitment0 = poseidon(NOTE_COMMITMENT_DOMAIN, noteBodyCommitment0, leafIndex0) noteCommitment1 = poseidon(NOTE_COMMITMENT_DOMAIN, noteBodyCommitment1, leafIndex0 + 1) noteCommitment2 = poseidon(NOTE_COMMITMENT_DOMAIN, noteBodyCommitment2, leafIndex0 + 2)Require all three final commitments are nonzero. Dummy outputs use nonzero dummy note commitments; inserting 0 is indistinguishable from the tree's empty leaf value.
Push the pre-insertion root to note-root history.
Insert the three final commitments in order.
Emit
ShieldedPoolTransact.
The pool proof is a fixed 256-byte Groth16 BN254 string encoding the canonical proof elements (A, B, C). Pool-proof verification MUST reject any malformed encoding.
ERC-20 calls in both transact and deposit MUST use the following exact semantics:
balanceOf(address(this))MUST be executed viastaticcall, MUST not revert, and MUST return exactly 32 bytes.transferFrom(msg.sender, address(this), amount)andtransfer(recipient, amount)MUST not revert and MUST satisfy one of:- returndata length is 0 and the target account has nonzero code length;
- returndata length is exactly 32 bytes decoding to
true.
- Any other returndata shape, empty returndata from an account with zero code length, or a decoded
falsereturn value MUST be treated as failure.
Fee-on-transfer and rebasing tokens are incompatible. The deposit-side balance-delta check rejects fee-on-transfer tokens; rebasing tokens are not reliably detectable. Tokens that charge fees only on outbound transfer (not on transferFrom) pass the deposit check but deliver less than the requested amount on withdrawal. Such tokens MUST NOT be deposited.
5.4.2 deposit
On each deposit call, the pool MUST execute the following steps:
Range checks.
- Require
amount > 0. - Require
amount < 2^248. - Require
ownerCommitment != 0. - Require
ownerCommitment < p(Section 3.5).
- Require
Receive public assets.
- If
token == address(0)(ETH): requiremsg.value == amount. - If
token != address(0)(ERC-20): requiremsg.value == 0. RecordbalBefore = balanceOf(address(this)). ExecutetransferFrom(msg.sender, address(this), amount)and require success. RequirebalanceOf(address(this)) - balBefore == amount.
- If
Assign leaf index. Require
nextLeafIndex + 1 <= 2^32. LetleafIndex = nextLeafIndex.Compute commitments.
noteBodyCommitment = poseidon( NOTE_BODY_COMMITMENT_DOMAIN, ownerCommitment, amount, uint160(token) ) noteCommitment = poseidon( NOTE_COMMITMENT_DOMAIN, noteBodyCommitment, leafIndex )Require
noteCommitment != 0.Insert the note.
- Push the pre-insertion root to note-root history.
- Insert the final note commitment.
Emit
ShieldedPoolDeposit.
The contract does not validate or decode outputNoteData. It does not prove or enforce on-chain that ownerCommitment corresponds to a registered address. The standard address-based deposit flow is off-chain discovery:
- sender resolves the recipient address or ENS name,
- sender reads
ownerNullifierKeyHashfrom the user registry and obtains any wallet-layer or companion-standard delivery information for the recipient, - sender chooses or derives
noteSecret, - sender computes
ownerCommitment = poseidon(OWNER_COMMITMENT_DOMAIN, ownerNullifierKeyHash, noteSecret), - sender calls
deposit.
Opaque owner-side commitments MAY also be coordinated out of band.
5.5 Pool Proof Verification
The system contract embeds the canonical Groth16 BN254 verification key and verifies pool proofs against that embedded key using the standard Groth16 verification equation. Replacing the verification key requires a hard fork.
- Public input vector: the 21 fields of
PublicInputs(Section 5.3), in declaration order. Each is a BN254 scalar field element per Section 3.5. - Proof encoding: a fixed 256-byte string encoding the canonical Groth16 BN254 proof elements
(A, B, C). Any other encoding is malformed. - Failure modes: the system contract MUST revert the calling
transacton any of: malformed proof encoding, any public input>= p(Section 3.5), or pairing-equation failure. - Verification key: in the standard Groth16 BN254 layout (
α ∈ G1;β, γ, δ ∈ G2;IC[0..21] ∈ G1), embedded in the system contract bytecode at fork-activation time.
6. Registries
6.1 User Registry
The shielded pool maintains a Poseidon Merkle tree mapping:
address → (ownerNullifierKeyHash, noteSecretSeedHash)
Root history follows the block-based model with window USER_REGISTRY_ROOT_HISTORY_BLOCKS.
Registration is REQUIRED before an address can spend notes through transact. Registration is also the standard address-based receive-discovery path for deposits: senders look up the recipient's ownerNullifierKeyHash from the registry. The contract does not enforce registration on deposit, so an unregistered address can still receive opaque deposits if the sender obtained ownerCommitment out of band. That is outside the standard address-based path.
Wallets and provers can read the current user-registry entry for a specific address via getUserRegistryEntry, and the current accepted roots via getCurrentRoots.
6.2 Registration Methods
The contract MUST provide:
registerUser(ownerNullifierKeyHash, noteSecretSeedHash)— callable bymsg.sender. MUST revert if the address is already registered.rotateNoteSecretSeed(newNoteSecretSeedHash)— callable bymsg.sender. MUST revert if the address is not registered.
All registration methods MUST respect the block-based root history invariant. All registration methods MUST compute the resulting user-registry leaf and revert if it equals 0.
All registration methods MUST enforce < p (Section 3.5) on every uint256 input hashed contract-side: ownerNullifierKeyHash and noteSecretSeedHash in registerUser, and newNoteSecretSeedHash in rotateNoteSecretSeed. Without these checks, congruent uint256 values would alias inside Poseidon2/BN254 and produce identical leaves under distinct mapping keys, breaking global ownerNullifierKeyHash uniqueness.
registerUser MUST additionally reject reserved ownerNullifierKeyHash values:
ownerNullifierKeyHash == 0— reserved sentinel.ownerNullifierKeyHash == DUMMY_OWNER_NULLIFIER_KEY_HASH— reserved for dummy output slots (Section 3.2). Without this check, a registered address mapping to this value would let an actor with access to the originating sender'snoteSecretSeedburn nullifiers for that sender's dummy outputs via the Section 9.3 registry binding. Rejecting the value at registration makes dummy notes structurally unspendable.
registerUser MUST enforce global ownerNullifierKeyHash uniqueness: the contract MUST revert if the ownerNullifierKeyHash index already maps to any address. On successful registration, the contract MUST write ownerNullifierKeyHashIndex[ownerNullifierKeyHash] = msg.sender atomically with the user-registry leaf insertion. rotateNoteSecretSeed does not touch the ownerNullifierKeyHash index because ownerNullifierKeyHash is immutable (Section 6.3).
6.3 Key Mutability
ownerNullifierKeyHash is immutable. ownerNullifierKey is therefore non-rotatable. If ownerNullifierKey is compromised, users can mitigate by rotating noteSecretSeed and auth methods, but they cannot change ownerNullifierKeyHash for existing notes.
noteSecretSeedHash is rotatable via rotateNoteSecretSeed. Rotating it does not affect ownership of existing notes, but changes the derived noteSecret used for future outputs after stale user roots expire. After rotation, users MUST retain the prior noteSecretSeed until the stale-root window (USER_REGISTRY_ROOT_HISTORY_BLOCKS blocks) expires and any transactions they authorized against the old root have either settled or been abandoned.
6.4 Auth Policy Registry
The auth-policy registry is composed of two trees.
The auth-policy registration tree (Section 3.4) is append-only. Each registerAuthPolicy call appends one leaf at a new leafPosition. The contract computes the leaf as poseidon(AUTH_POLICY_DOMAIN, uint160(msg.sender), policyCommitment) so the user field is msg.sender by construction; a caller cannot plant a leaf claiming another address. The leaf's remaining preimage fields — authVerifier, authDataCommitment, and registrationBlinder — are hidden inside the caller-submitted policyCommitment. The emitted event and on-chain state reveal only (user, leafPosition, leafValue); observers cannot recover the (user, authVerifier) mapping without guessing registrationBlinder.
The auth-policy revocation tree (Section 3.4) is a depth-32 sparse tree keyed directly by leafPosition. deregisterAuthPolicy(leafPosition) requires msg.sender to be the original registrant (checked via authPolicyOwner[leafPosition]); the contract then writes 1 at that position. This msg.sender gate is the only authentication needed — there is no nullifier preimage to derive and no blinder is exposed in calldata.
Wallets and provers can read the current accepted roots via getCurrentRoots and test whether a specific leafPosition is revoked via isRevokedAuthPolicy. No view method exposes the (user, authVerifier) mapping; by design, that mapping is not public on-chain.
Wallet-side state. To spend through a registration, a wallet MUST retain enough metadata to recover (authVerifier, authDataCommitment, registrationBlinder, leafPosition). To revoke a registration, it only needs leafPosition and control of the registering address; leafPosition is public and recoverable from AuthPolicyRegistered events. Losing registration metadata therefore does not create permanent auth exposure: the user can revoke old leaves and register fresh policies.
The pool circuit proves (a) registration-tree membership for the opened leafPosition and (b) revocation-tree non-membership at the same leafPosition; see Section 9.
Rotation and revocation. Rotation is deregisterAuthPolicy(leafPosition_old) followed by registerAuthPolicy(policyCommitment_new) with a fresh registrationBlinder. Revocation is bounded-delay: the old revocation-tree root remains valid for up to AUTH_POLICY_ROOT_HISTORY_BLOCKS blocks. During this window, in-flight spends may still prove against older revocation roots in the history. After the window, spends through the revoked leaf fail revocation-tree non-membership.
Revocation disables a registration leaf; it is not a general cancellation primitive for every already-issued authorization. If a user re-registers the same verifier and auth data before an unexpired authorization is used, that authorization may still be usable through the new leaf. Wallets that need cancellation semantics should rely on short validUntilSeconds windows and nonce consumption.
Adding a new auth method. To add a new auth method:
- Publish an auth circuit and its corresponding
authVerifierSolidity contract per Section 12. - Users register their auth data via
registerAuthPolicy(policyCommitment)withpolicyCommitmentcomputed over the newauthVerifier. Existing auth policies remain active — the new registration appends an independent leaf. - Done — no hard fork required.
Constraints: the auth circuit MUST conform to the auth-proof relation in Section 9.1. Companion ERCs MUST define an authorization relation that binds all intent-digest fields, including authVerifier and nonce, and additionally binds blindingFactor even though blindingFactor is excluded from transactionIntentDigest. Companion ERCs MUST treat each authorization-bound value as a single BN254 scalar field element per Section 3.5. Because authVerifier is bound inside transactionIntentDigest, an authorization cannot be retargeted to a different auth verifier.
Cross-circuit note compatibility. Note commitments bind to ownerNullifierKeyHash and do not encode an auth method. A note created when one auth verifier was used is spendable with any other auth verifier, provided the address bound to that ownerNullifierKeyHash has an active (unrevoked) registration for the spending verifier.
All auth circuits share the same note tree, nullifier set, and anonymity set — adding a new auth method requires only a new registerAuthPolicy call, not a fund transfer. Both old and new auth methods remain usable simultaneously.
These auth-method extensions govern spend authorization only. registerUser, rotateNoteSecretSeed, registerAuthPolicy, and deregisterAuthPolicy remain direct msg.sender-gated lifecycle methods. Users who want multisig or contract-governed lifecycle control SHOULD use a smart-contract wallet address as the registered account namespace.
7. Note Commitment and Nullifiers
7.1 Address and Amount Constraints
Inside the pool circuit for transact:
- all address-valued witnesses (
authorizingAddress,tokenAddress,recipientAddress,feeRecipientAddress,feeNoteRecipientAddress) MUST be constrained to< 2^160. Without this, field aliasing could produce commitments or public inputs that pass proof verification but bind to different addresses than the EVM expects. - amounts MUST be constrained to
< 2^248. leafPositionMUST be constrained to< 2^32, matching the depth-32 registration and revocation trees.
Contract-side, the pool MUST reject:
publicRecipientAddress,publicTokenAddress, orauthVerifiervalues>= 2^160before interpreting them as EVM addresses intransact.publicAmountOut >= 2^248intransactand depositamount >= 2^248indeposit.
Deposit-time token enters the contract already typed as a Solidity address value and therefore carries the 160-bit range by construction; no additional range check is required before it is hashed into noteBodyCommitment.
7.2 ownerNullifierKeyHash
ownerNullifierKeyHash MUST be computed as:
ownerNullifierKeyHash = poseidon(
OWNER_NULLIFIER_KEY_HASH_DOMAIN,
ownerNullifierKey
)
ownerNullifierKeyHash is stored in the user registry and is the hidden ownership identifier bound into notes.
7.3 ownerCommitment
The owner-side note commitment MUST be computed as:
ownerCommitment = poseidon(
OWNER_COMMITMENT_DOMAIN,
ownerNullifierKeyHash,
noteSecret
)
ownerCommitment hides both ownerNullifierKeyHash and noteSecret from on-chain observers. On deposit, the contract treats ownerCommitment as an uninterpreted uint256 — it does not derive ownerNullifierKeyHash or noteSecret from it and does not verify its construction. On transact, it is a private witness reconstructed inside the pool circuit from the spender's ownerNullifierKeyHash and the note's noteSecret.
7.4 Note Body Commitment
The semantic note commitment MUST be computed as:
noteBodyCommitment = poseidon(
NOTE_BODY_COMMITMENT_DOMAIN,
ownerCommitment,
amount,
tokenAddress
)
This binds the note's owner-side fragment, amount, and token.
7.5 Final Note Commitment
The final inserted note commitment MUST be computed as:
noteCommitment = poseidon(
NOTE_COMMITMENT_DOMAIN,
noteBodyCommitment,
leafIndex
)
leafIndex is the sequential note-tree leaf index assigned by the contract at insertion time. This is the structural uniqueness source for notes.
7.6 Nullifier
A real input note nullifier MUST be computed as:
nullifier = poseidon(
NULLIFIER_DOMAIN,
noteCommitment,
ownerNullifierKey
)
This formula is mode-agnostic: it applies to notes created by deposit and to notes created by transact.
7.7 Phantom Nullifier
If an input slot is phantom, the circuit MUST use:
phantomNullifier = poseidon(
PHANTOM_NULLIFIER_DOMAIN,
ownerNullifierKey,
intentReplayId,
inputIndex
)
inputIndexis 0 or 1.PHANTOM_NULLIFIER_DOMAINprevents collision with real note nullifiers.intentReplayIdprovides per-transaction uniqueness.
The contract MUST treat phantom nullifiers indistinguishably from real nullifiers.
7.8 Note Secret Seed
The note-secret seed MUST hash to:
noteSecretSeedHash = poseidon(
NOTE_SECRET_SEED_DOMAIN,
noteSecretSeed
)
noteSecretSeed is the rotatable root from which wallets derive future note secrets they control. noteSecretSeed governs only transact-output note-secret derivation (Section 7.9); deposit noteSecret is wallet-chosen. Rotating noteSecretSeed cuts off future transact-output note-secret derivation by provers or coordinators that only knew the old seed after stale user roots expire.
7.9 Note Secret
noteSecret is the per-note hidden blinder. Wallets MUST NOT reuse noteSecret across notes they create, because reuse creates linkability. Nullifier safety does not depend on noteSecret uniqueness in this design because structural note uniqueness comes from leafIndex.
For ordinary transact outputs, the circuit MUST derive:
noteSecret = poseidon(
TRANSACT_NOTE_SECRET_DOMAIN,
noteSecretSeed,
intentReplayId,
outputIndex
)
Here outputIndex is 0, 1, or 2.
For deposits, the depositor chooses noteSecret using any recoverable wallet-side rule or randomness and conveys it to the recipient through outputNoteData or out-of-band coordination. The contract does not validate noteSecret or its derivation. Standardized wallet-side derivations MAY be defined by companion ERCs.
8. Operation Modes
The pool supports three user-visible operations:
- deposit — public asset movement into one private note.
- transfer — private-note spend into new private notes.
- withdrawal — private-note spend into public asset movement.
Deposits are executed by deposit. Transfers and withdrawals are executed by transact.
8.1 Deposit
Requirements:
amount > 0.- the depositor supplies an
ownerCommitment, - the contract receives ETH or ERC-20 funds publicly.
The standard address-based receive path for deposits is:
- sender resolves recipient address or ENS,
- sender fetches
ownerNullifierKeyHashand any wallet-layer or companion-standard delivery information for the recipient, - sender constructs
ownerCommitmentandoutputNoteData, - sender calls
deposit.
The contract does not enforce on-chain that the recipient is registered or that ownerCommitment matches a registered address.
8.2 Transfer
Private transfer uses transact with:
publicAmountOut == 0,publicRecipientAddress == 0,publicTokenAddress == 0.
At least one input MUST be real.
8.3 Withdrawal
Withdrawal uses transact with:
publicAmountOut > 0,publicRecipientAddress != 0,publicTokenAddressequal to the withdrawn ERC-20 contract address, or0for ETH.
At least one input MUST be real.
9. Pool Circuit Requirements
9.1 Pool Circuit Interface and Auth Proof Coupling
The pool circuit MUST:
- prove user-registry membership for
authorizingAddressagainstregistryRootand extract the sender'sownerNullifierKeyHashandnoteSecretSeedHash, - enforce the range constraint
leafPosition < 2^32(Section 7.1), - prove auth-policy registration-tree membership for the witnessed
leafPositionagainstauthPolicyRegistrationRoot, where the opened leaf equalsposeidon(AUTH_POLICY_DOMAIN, uint160(authorizingAddress), policyCommitment)withpolicyCommitment = poseidon(POLICY_COMMITMENT_DOMAIN, uint160(authVerifier), authDataCommitment, registrationBlinder), - prove auth-policy revocation-tree non-membership at key
leafPositionagainstauthPolicyRevocationRoot(the leaf at that position MUST equal0), - recompute
blindedAuthCommitment = poseidon(BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor)and enforce equality with public inputblindedAuthCommitment, - recompute
transactionIntentDigestper Section 9.10 and enforce equality with public inputtransactionIntentDigest, - derive
intentReplayIdper Section 9.8 and enforce that the derived value equals public inputintentReplayId, - validate input note ownership and nullifiers,
- validate output note-body commitments and output bindings,
- enforce value conservation and token consistency.
authVerifier, blindedAuthCommitment, and transactionIntentDigest are public inputs (Section 10). authDataCommitment, blindingFactor, registrationBlinder, leafPosition, and the Merkle paths are private witnesses. All constraints MUST be expressed over the BN254 scalar field per Section 3.5.
Auth proof relation. Each auth circuit and its corresponding authVerifier Solidity contract (Section 12) MUST prove knowledge of the auth data committed by authDataCommitment, the canonical authDataCommitment derivation from that auth data, and satisfaction of a verifier-defined authorization relation that binds every transactionIntentDigest input (Section 9.10) plus blindingFactor, such that:
- the intent's
authVerifierfield equals the Solidity address of the verifier contract handling theverifyAuthcall. Companion standards define how a verifier binds its own address into the auth proof relation. - public output 0 equals
poseidon(BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor); - public output 1 equals the Section 9.10 formula (which excludes
blindingFactor); - neither
ownerNullifierKeynornoteSecretSeedappears in the auth proof relation.
Auth-proof public inputs are exactly [blindedAuthCommitment, transactionIntentDigest], in that order. The system contract passes those two values from the pool proof's public inputs into the auth verifier (Section 5.4.1 step 10). This is the cross-proof coupling; neither proof verifies the other directly. Nonce and blinding-factor freshness are wallet obligations (Security Considerations).
9.2 Input Ownership and Membership
For each input slot:
- If
isPhantom == 0(real input):- the circuit MUST prove Merkle membership in
noteCommitmentRoot, - the circuit MUST recompute
ownerNullifierKeyHash = poseidon(OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey), - the circuit MUST recompute
ownerCommitment,noteBodyCommitment,noteCommitment, andnullifier, - the circuit MUST enforce that the recomputed
noteCommitmentequals the committed leaf being opened.
- the circuit MUST prove Merkle membership in
- If
isPhantom == 1(phantom input):- membership MUST be skipped,
- the circuit MUST enforce
phantomNullifier = poseidon(PHANTOM_NULLIFIER_DOMAIN, ownerNullifierKey, intentReplayId, inputIndex), amount = 0.
isPhantom MUST be constrained to 0 or 1.
At least one input MUST be real.
The recomputed input nullifier for slot i MUST equal public input nullifier_i for i ∈ {0, 1}. This applies whether the slot is real (nullifier derived per Section 7.6) or phantom (nullifier derived per the phantom-nullifier rule above).
9.3 Sender ownerNullifierKeyHash and Note-Secret-Seed Binding
In all spend modes, the circuit MUST enforce:
senderOwnerNullifierKeyHash = poseidon(OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey)
and:
senderOwnerNullifierKeyHash == registryOwnerNullifierKeyHash(authorizingAddress)
where registryOwnerNullifierKeyHash(authorizingAddress) is extracted from the sender's user-registry leaf. ownerNullifierKey is a single pool-circuit witness reused across all real input slots, across the senderOwnerNullifierKeyHash recomputation above, and across phantom-nullifier derivation. The circuit MUST NOT instantiate per-slot ownerNullifierKey witnesses.
The circuit MUST also enforce:
poseidon(NOTE_SECRET_SEED_DOMAIN, noteSecretSeed) == registryNoteSecretSeedHash(authorizingAddress)
This binds ordinary output note-secret derivation to the sender's registered, rotatable note-secret seed.
9.4 Value Conservation
The circuit MUST enforce:
sum(input_amounts) == sum(output_amounts) + publicAmountOut
Both sides MUST include range checks to prevent overflow.
9.5 Output Well-Formedness and Determinism
For each output slot i ∈ {0, 1, 2} (corresponding to public output noteBodyCommitment_i), the circuit witnesses ownerNullifierKeyHash_i, noteSecret_i, amount_i, tokenAddress_i, and an isDummy_i flag constrained to 0 or 1. Subscripted fields are slot-local; bare amount is the transaction-intent amount.
For every output slot i, regardless of whether it is real or dummy, the circuit MUST:
- deterministically derive
noteSecret_i, - compute
ownerCommitment_i = poseidon(OWNER_COMMITMENT_DOMAIN, ownerNullifierKeyHash_i, noteSecret_i), - compute
noteBodyCommitment_i = poseidon(NOTE_BODY_COMMITMENT_DOMAIN, ownerCommitment_i, amount_i, tokenAddress_i), - enforce
noteBodyCommitment_i == public noteBodyCommitment_i.
Then:
- If
isDummy_i == 0(real output):- real outputs MUST have
amount_i > 0.
- real outputs MUST have
- If
isDummy_i == 1(dummy output):amount_i == 0,tokenAddress_i == 0,ownerNullifierKeyHash_i == DUMMY_OWNER_NULLIFIER_KEY_HASH.
Additional per-mode constraints:
- Transfer
- output slot 0 is the recipient payment:
isDummy_0 == 0,ownerNullifierKeyHash_0MUST equalregistryOwnerNullifierKeyHash(recipientAddress),amount_0MUST equal the authorized private amount, andtokenAddress_0MUST equal the authorized private token. - output slot 1 is sender change or dummy: if
isDummy_1 == 0,ownerNullifierKeyHash_1MUST equalsenderOwnerNullifierKeyHash. - output slot 2 is a fee note or dummy.
- output slot 0 is the recipient payment:
- Withdrawal
- output slot 0 is sender change or dummy: if
isDummy_0 == 0,ownerNullifierKeyHash_0MUST equalsenderOwnerNullifierKeyHash. - output slot 1 MUST be dummy.
- output slot 2 is a fee note or dummy.
- output slot 0 is sender change or dummy: if
For output slot 2 specifically:
feeAmount == 0iff output slot 2 is dummy, and thenfeeRecipientAddress == 0,feeAmount > 0iff output slot 2 is real,- if slot 2 is real,
amount_2MUST equalfeeAmount, - if
feeAmount == 0,feeNoteRecipientAddress == 0, - if slot 2 is real,
feeNoteRecipientAddressMUST be nonzero, - if
feeAmount > 0andfeeRecipientAddress != 0, then the circuit MUST enforcefeeNoteRecipientAddress == feeRecipientAddress, - if slot 2 is real,
ownerNullifierKeyHash_2MUST equalregistryOwnerNullifierKeyHash(feeNoteRecipientAddress).
The note secret MUST be deterministically derived for both real and dummy ordinary outputs:
noteSecret_i = poseidon(
TRANSACT_NOTE_SECRET_DOMAIN,
noteSecretSeed,
intentReplayId,
i
)
Note-secret derivation is deterministic given a fixed witness assignment. Coin selection, output assignment, and registry root selection within the valid history window are not canonicalized.
9.6 Registry Binding
- Transfer
- prove the recipient address has a user-registry entry and extract recipient
ownerNullifierKeyHash, - prove the sender has a user-registry entry and extract sender
ownerNullifierKeyHashandnoteSecretSeedHash, - if
feeAmount != 0, provefeeNoteRecipientAddresshas a user-registry entry and extract itsownerNullifierKeyHash, - prove registration-tree leaf membership at the witnessed
leafPositionand revocation-tree non-membership at the sameleafPosition.
- prove the recipient address has a user-registry entry and extract recipient
- Withdrawal
- prove the sender has a user-registry entry and extract sender
ownerNullifierKeyHashandnoteSecretSeedHash, - if
feeAmount != 0, provefeeNoteRecipientAddresshas a user-registry entry and extract itsownerNullifierKeyHash, - prove registration-tree leaf membership at the witnessed
leafPositionand revocation-tree non-membership at the sameleafPosition.
- prove the sender has a user-registry entry and extract sender
publicRecipientAddress in a withdrawal does not need a registry entry because it receives public assets, not a private note.
9.7 Output Note Data and Output Binding
outputNoteDataHash0, outputNoteDataHash1, and outputNoteDataHash2 are public inputs that bind opaque note-delivery payloads to the proof. They are computed as outputNoteDataHash_i = uint256(keccak256(outputNoteData_i)) mod p, where p is the BN254 scalar field order (Section 3.1). The mod p reduction is required because each public input must be a canonical BN254 scalar field element (Section 3.5), and a raw keccak256 output can exceed p. The prover and the contract independently compute this value and verify equality.
For each slot i, the pool circuit MUST compute:
outputBinding_i = poseidon(
OUTPUT_BINDING_DOMAIN,
noteBodyCommitment_i,
outputNoteDataHash_i
)
Execution constraints MAY lock any subset of these outputBinding_i values. If a slot is locked, the prover cannot change either the semantic note contents or the emitted payload bytes for that slot after authorization. The final inserted noteCommitment includes a contract-assigned leaf index and is therefore not itself the authorization-lock target.
The pool and auth circuits do not validate encryption scheme semantics or delivery format.
9.8 Intent Replay ID
All private-note spends use the same intent replay ID derivation:
intentReplayId = poseidon(
INTENT_REPLAY_ID_DOMAIN,
ownerNullifierKey,
authorizingAddress,
executionChainId,
nonce
)
Reusing the same nonce within the same (ownerNullifierKey, authorizingAddress, executionChainId) replay domain makes those authorizations mutually exclusive even when their payment fields or execution constraints differ. Wallets MUST choose a fresh uniformly-random nonce with at least 128 bits of entropy for each new authorization.
The derived intentReplayId MUST equal public input intentReplayId.
9.9 Token Consistency
All real input and output notes MUST use the same tokenAddress.
- Withdrawal:
tokenAddress == publicTokenAddress. - Transfer:
publicTokenAddress == 0.
9.10 Transaction Intent Digest
The auth circuit authenticates this digest; the pool circuit recomputes it from witnesses, public inputs, and mode-derived values and enforces equality.
transactionIntentDigest = poseidon(
TRANSACTION_INTENT_DIGEST_DOMAIN,
authVerifier,
authorizingAddress,
operationKind,
tokenAddress,
recipientAddress,
amount,
feeRecipientAddress,
feeAmount,
executionConstraintsFlags,
lockedOutputBinding0,
lockedOutputBinding1,
lockedOutputBinding2,
nonce,
validUntilSeconds,
executionChainId
)
feeRecipientAddressMAY be zero (Section 9.5).nonceMUST be uniformly random (Section 9.8). It supplies replay protection and prevents brute-force confirmation of the digest preimage.
The pool circuit MUST derive operationKind from the public execution mode:
publicAmountOut > 0→WITHDRAWAL_OPpublicAmountOut == 0→TRANSFER_OP
Normative execution-field binding
- Withdrawal
recipientAddress == publicRecipientAddressamount == publicAmountOuttokenAddress == publicTokenAddressvalidUntilSeconds== public inputexecutionChainId == block.chainid(checked by contract)feeRecipientAddressandfeeAmountare private witnesses bound through intent-digest computation.
- Transfer
recipientAddress,amount,feeRecipientAddress, andfeeAmountare private, bound through intent-digest computation, output constraints, and value conservation.tokenAddressis private, bound through token consistency (Section 9.9).validUntilSeconds== public inputexecutionChainId == block.chainid(checked by contract)publicRecipientAddress == 0publicAmountOut == 0publicTokenAddress == 0
9.11 Execution Constraints
Execution constraints let an authorization optionally bind finalized output slots without changing the nonce-based replay domain. The authorization-bound fields executionConstraintsFlags, lockedOutputBinding0, lockedOutputBinding1, and lockedOutputBinding2 are inputs to transactionIntentDigest (Section 9.10).
executionConstraintsFlags < 2^32. Any bit other thanLOCK_OUTPUT_BINDING_0,LOCK_OUTPUT_BINDING_1,LOCK_OUTPUT_BINDING_2MUST cause proof failure.- For each
i ∈ {0, 1, 2}: ifexecutionConstraintsFlags & LOCK_OUTPUT_BINDING_i != 0, thenlockedOutputBinding_i == outputBinding_i; otherwiselockedOutputBinding_i == 0.
10. Public Inputs
The pool proof's public-input vector is the 21 fields of PublicInputs, in declaration order. Each uint256 field is interpreted by the Groth16 verifier as a single BN254 scalar field element per Section 3.5.
noteCommitmentRoot— note-commitment-tree root the proof is verified against.nullifier0,nullifier1— input note nullifiers.noteBodyCommitment0,noteBodyCommitment1,noteBodyCommitment2— semantic output note commitments.publicAmountOut— public withdrawal amount; 0 for transfers.publicRecipientAddress— withdrawal destination address; 0 for transfers.publicTokenAddress— withdrawn token address; 0 for transfers.intentReplayId— replay protection.registryRoot— user registry root. MUST be nonzero.validUntilSeconds— intent expiry timestamp. MUST be > 0 and <2^32.executionChainId— verified by the contract againstblock.chainid.authPolicyRegistrationRoot— auth-policy registration-tree root. MUST be nonzero.authPolicyRevocationRoot— auth-policy revocation-tree root. MUST be nonzero.outputNoteDataHash0,outputNoteDataHash1,outputNoteDataHash2—uint256(keccak256(outputNoteData_i)) mod p; see Section 9.7.authVerifier— address of the auth verifier contract dispatched to in Section 5.4.1 step 10. MUST be nonzero and< 2^160.blindedAuthCommitment— the value also taken as the auth proof's first public input.transactionIntentDigest— the value also taken as the auth proof's second public input.
executionConstraintsFlags, lockedOutputBinding0, lockedOutputBinding1, lockedOutputBinding2, nonce, authDataCommitment, and blindingFactor are private authorization-bound values checked inside the proof relation. registrationBlinder and leafPosition are private registration witnesses.
10.1 Public Input Range Validation
Every public input MUST be a canonical BN254 scalar field element (< p); the system contract rejects any non-canonical value (Section 5.5). In addition, the system contract enforces the following per-field range checks at Section 5.4.1 step 8: publicAmountOut < 2^248; publicRecipientAddress < 2^160, publicTokenAddress < 2^160, authVerifier < 2^160, authVerifier != 0; validUntilSeconds < 2^32. These checks prevent non-address values aliasing into EVM-address slots and prevent amount overflow in the balance equation.
11. Poseidon Hash Contexts
Inputs are listed in declaration order. Each input is a single BN254 scalar field element (Section 3.5); the Section 3.3 length-tagged sponge consumes them in 3-element chunks. Arity is the number of input field elements (excluding length-tag bookkeeping inside the sponge state).
| Context | Inputs (in order) | Arity |
|---|---|---|
| ownerNullifierKeyHash | OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey |
2 |
| ownerCommitment | OWNER_COMMITMENT_DOMAIN, ownerNullifierKeyHash, noteSecret |
3 |
| noteBodyCommitment | NOTE_BODY_COMMITMENT_DOMAIN, ownerCommitment, amount, tokenAddress |
4 |
| noteCommitment | NOTE_COMMITMENT_DOMAIN, noteBodyCommitment, leafIndex |
3 |
| Nullifier | NULLIFIER_DOMAIN, noteCommitment, ownerNullifierKey |
3 |
| Phantom nullifier | PHANTOM_NULLIFIER_DOMAIN, ownerNullifierKey, intentReplayId, inputIndex |
4 |
| Note secret seed hash | NOTE_SECRET_SEED_DOMAIN, noteSecretSeed |
2 |
| Ordinary note secret | TRANSACT_NOTE_SECRET_DOMAIN, noteSecretSeed, intentReplayId, outputIndex |
4 |
| Intent replay ID | INTENT_REPLAY_ID_DOMAIN, ownerNullifierKey, authorizingAddress, executionChainId, nonce |
5 |
| Transaction intent digest | TRANSACTION_INTENT_DIGEST_DOMAIN, authVerifier, authorizingAddress, operationKind, tokenAddress, recipientAddress, amount, feeRecipientAddress, feeAmount, executionConstraintsFlags, lockedOutputBinding0, lockedOutputBinding1, lockedOutputBinding2, nonce, validUntilSeconds, executionChainId |
16 |
| Output binding | OUTPUT_BINDING_DOMAIN, noteBodyCommitment, outputNoteDataHash |
3 |
| Policy commitment | POLICY_COMMITMENT_DOMAIN, authVerifier, authDataCommitment, registrationBlinder |
4 |
| Auth policy leaf | AUTH_POLICY_DOMAIN, user, policyCommitment |
3 |
| Blinded auth commitment | BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor |
3 |
| User registry leaf | USER_REGISTRY_LEAF_DOMAIN, user, ownerNullifierKeyHash, noteSecretSeedHash |
4 |
| Merkle tree node | left, right |
2 |
Address-typed inputs are absorbed as uint160 field elements; uint32-typed inputs as uint32 field elements; amount and feeAmount carry an additional in-circuit < 2^248 constraint (Section 7.1).
12. Auth Verifier Contract
Each auth circuit has a corresponding authVerifier Solidity contract. Anyone may deploy an auth verifier contract; the system contract dispatches to whichever address the user has registered in their auth policy.
12.1 Interface
An auth verifier contract MUST implement:
interface IAuthVerifier {
function verifyAuth(
bytes calldata publicInputs,
bytes calldata proof
) external returns (bool);
}
publicInputsis exactlyabi.encode(blindedAuthCommitment, transactionIntentDigest), where both values areuint256.proofis the auth proof bytes in whatever encoding the auth verifier expects.
12.2 Verification Semantics
The system contract MUST invoke verifyAuth via staticcall with the auth proof and encoded public inputs taken from the pool proof's public inputs. The system contract MUST treat any of the following as verification failure (and revert the transact call):
- the staticcall reverts,
- returndata length is not exactly 32 bytes,
- the decoded boolean return value is
false, - the auth verifier address has zero code length.
The system contract's staticcall enforces read-only execution. Any auth verifier behavior that causes the staticcall to fail is treated as proof failure.
A malicious or buggy auth verifier can validate proofs that should fail, but cannot extend its compromise beyond users registered at its address; the pool circuit independently enforces all pool-critical invariants. Companion ERCs SHOULD specify the canonical auth-circuit relation, the verifyAuth proof format, and any verification-key derivation rules sufficient for third-party audit.
13. Output Note Data
Note delivery, meaning how senders convey enough information for recipients to recover output notes, is not specified by this EIP. Wallets MAY coordinate delivery out of band, and companion standards MAY define shared registries or encryption formats. This EIP treats outputNoteData bytes as opaque. In transact, outputNoteData_i is hash-bound as defined in Section 9.7; in deposit, outputNoteData is emitted opaquely and is not proof-bound.
Rationale
System Contract, Fork-Managed Pool Circuit, and No Admin Pause
The pool is a protocol-managed account at a fixed address because its security depends on global state, not on a single application. The system contract has no upgrade key, no proxy, and no pause path. Changes require a hard fork.
A bug in the ZK scheme can compromise funds held in the pool but does not alter consensus rules, the validator set, or ETH supply semantics. Native integration (e.g., EIP-7503) can expose the protocol itself to ZK-scheme failures, including unbounded minting. The ZK-scheme risk to depositors is equivalent to existing app-level pools.
A deposit-only pause triggered by a consensus-layer flag was considered and rejected. Any pause trigger reintroduces a governance surface; a withdrawal freeze during a false alarm locks user funds pending a hard fork to unpause. The scope of a soundness exploit (pool-held funds only, not protocol consensus) makes the hard-fork remediation timeline acceptable relative to the governance risk of a pause mechanism.
Split Proof Architecture and Private Auth-Policy Registration
The complexity of pluggable auth via a private policy registry is the cost of avoiding worse complexity: managing specific auth methods at the protocol level would freeze users to one scheme or require a hard fork per addition, with all the governance overhead that implies. The current design keeps the spec stable while letting auth methods evolve permissionlessly.
Pool invariants live in the fork-managed pool circuit, verified by the system contract (Section 5.5); auth verification lives in user-registered authVerifier contracts, deployable permissionlessly. A malicious auth verifier can only risk users registered at its address — the pool circuit independently enforces value conservation, nullifiers, deterministic note-secret derivation, and auth-policy checks. Adding a new auth method is one registerAuthPolicy call with no fund transfers and no anonymity-set fragmentation, because the registry hides the user-to-verifier mapping: registration leaves are poseidon(AUTH_POLICY_DOMAIN, uint160(user), policyCommitment) with authVerifier and authDataCommitment blinded under a user secret; revocation is keyed by leafPosition and msg.sender-gated; events emit no verifier data. A random nonce keeps transactionIntentDigest from being brute-forced to confirm intent fields, and blindingFactor hides the registered auth data in blindedAuthCommitment. Both are bound by the auth proof's authorization relation.
Hardware Wallet Support
Spend authorization is provided as a credential (typically a signature) that the auth circuit consumes as a witness. Because producing the credential is much cheaper than generating the proof, a constrained device such as a hardware wallet can sign while the proof is generated separately on a more capable device.
Groth16 BN254 Pool Proof System
Soundness rests on Poseidon2 collision-/preimage-resistance, the BN254 q-DLOG / pairing assumptions underlying Groth16, and a one-time multi-party trusted-setup ceremony. Groth16 BN254 has the smallest proof size and verifier gas cost of the major BN254 SNARK families; native mobile provers (e.g. rapidsnark) ship prebuilt for iOS/Android arm64. The Section 3.3 length-tagged sponge initializes capacity to N << 64, so a 2-input Merkle node and any arity-≥ 3 application hash start in distinct sponge states; the spec therefore omits a MERKLE_NODE_DOMAIN tag without weakening cross-context collision resistance.
The reference implementation's pool circuit is 223,043 R1CS constraints, produces a 256-byte Groth16 proof, and ships a 131 MB development zkey. Native rapidsnark proving on an M5 Max measured approximately 0.68 seconds. These measurements are non-normative; they indicate mobile implementations should use native provers at this circuit size.
Pool-proof verification at this circuit size measures approximately 335K gas in the reference implementation, dominated by ECADD / ECMUL / ECPAIRING calls on the 21 public-input scalar multiplications and the final pairing.
Future PQ Migration
Groth16 over BN254 is not post-quantum secure. The proof system is fork-swappable via system contract code replacement, and the on-chain state schema (tree shapes, domain tags, preimage layouts, public-input layout, intent format) is defined independently of the proof system, so a future verifier consuming the same logical relation accepts the same state. State survives a PQ fork iff the future prover can prove the BN254-Poseidon2 relation. The swap is a single hard-fork event: the verifier in the system contract changes once, and every user's existing notes and registry entries continue to work without per-user migration.
A post-quantum proof system was not selected for the pool at activation because even at aggressive parameters a direct STARK proof exceeds the practical L1 calldata target (>167 KB in our measurements). This EIP therefore preserves an explicit future verifier-swap path rather than imposing that cost at activation.
Hidden Owner IDs with Address-Scoped UX
Users still share addresses and ENS names. Those addresses remain the canonical namespace for auth policies and wallet discovery. Notes themselves bind to hidden ownerNullifierKeyHashs instead of literal addresses. This keeps the user-facing model Ethereum-native while removing the need for deposit-side note creation to know a recipient address as an input to the note commitment itself.
Proof-Free Deposits
Deposits are public asset movements into the pool. Making them contract-native avoids spending proof overhead where no private-note input is being consumed. Private-note spending still requires a proof and remains the hard security boundary.
Constrained vs Wallet-Chosen Note Secrets
transact and deposit treat noteSecret differently by design. In transact, an unconstrained noteSecret would give the prover discretion over note openings and recovery-sensitive randomness. The pool circuit therefore pins noteSecret = poseidon(TRANSACT_NOTE_SECRET_DOMAIN, noteSecretSeed, intentReplayId, outputIndex), tying it to the authorizing address's registered seed, the authorization nonce, and the output slot. In deposit, there is no prover to discipline: the depositor constructs the note directly and conveys whatever noteSecret they chose to the recipient via outputNoteData or out of band. Nullifier uniqueness no longer depends on noteSecret structure — the contract-assigned leafIndex carries that role — so removing the protocol-level derivation from the deposit path does not weaken any safety invariant.
Two-Layer Note Commitment
Splitting note creation into ownerCommitment, noteBodyCommitment, and final noteCommitment lets ordinary private-note spends preserve privacy while letting deposits and contract-completed flows finalize note insertion with a contract-assigned leaf index. Output locking binds the semantic note (noteBodyCommitment) plus payload hash rather than the insertion-specific final leaf.
Leaf-Index Uniqueness
Using the assigned leaf index in the final note commitment guarantees uniqueness even when two notes share the same semantic contents. This removes nullifier-collision dependence on note-secret derivation structure while still requiring wallets to avoid note-secret reuse for privacy.
Out-of-Protocol Compliance
This EIP does not include any in-protocol compliance primitives — origin tags, allowlist identifiers, risk scores, or provenance propagation rules. Encoding a specific compliance model at the protocol layer is less expressive than what can be built on top, commits the protocol to one model prematurely, and makes the compliance surface subject to hard-fork governance rather than companion-standard iteration. Disclosure formats and compliance workflows belong in companion standards and off-chain infrastructure built over the public deposit and withdrawal record.
Finalized Output Binding
outputBinding = poseidon(OUTPUT_BINDING_DOMAIN, noteBodyCommitment, outputNoteDataHash) binds one emitted semantic note commitment to one output-note-data hash. Execution constraints use this binding to lock finalized output slots.
Private Fee Compensation
The system contract charges no protocol-level fee. The protocol's mandatory onchain cost is Ethereum gas. Prover or broadcaster compensation, if any, is optional and user-authorized via output slot 2 rather than imposed by the pool.
Private transfers need a way to compensate a broadcaster or sponsor without revealing the transferred token on-chain. A public fee output would leak the asset for shielded transfers, so this EIP reserves output slot 2 for an optional private fee note. If feeRecipientAddress is nonzero, the authorization designates the fee recipient and the circuit binds feeNoteRecipientAddress to it. If feeRecipientAddress is zero and feeAmount > 0, the prover chooses feeNoteRecipientAddress at proof generation time, but that choice is still fixed by the resulting proof and cannot be changed at broadcast time. Keeping fee compensation inside the same note model also makes the design compatible with both legacy transactions and future transaction types that separate sender from gas payer: the transaction layer can decide who submits and who pays gas, while the pool continues to express compensation as an ordinary shielded note in the transferred asset.
UTXO-Based Notes over Account-Based Encrypted Balances
Account-based encrypted balances reveal access patterns — which accounts transact and how frequently — even when amounts are hidden. UTXO-based notes avoid this: spending a note produces new commitments, and shielded transfers within the pool reveal nothing about amounts, tokens, or counterparties on-chain.
Backwards Compatibility
This EIP defines a new system contract and a new pool proof relation, all activated by the same hard fork. It does not modify the semantics of existing contracts or existing ERC-20 interfaces.
Test Cases
Normative test coverage MUST include at least:
- Poseidon2/BN254 parameter and vector assets load and verify.
- Pool proof verification in
transactaccepts a valid Groth16 pool proof against the embedded VK with a 21-field public-input vector, and rejects malformed encodings, non-canonical public inputs (>= p), or pairing failure; pool-proof rejection also whenauthVerifier == 0,>= 2^160, or has no deployed code. - User registration, seed rotation, auth-policy registration, and auth-policy deregistration; duplicate
ownerNullifierKeyHashrejection. - Auth-proof envelope malformed-bytes rejection; auth-verifier dispatch failure (staticcall revert, returndata not 32 bytes, or decoded
false). - Address, amount, and public-input range rejection:
publicRecipientAddress/publicTokenAddress>= 2^160,publicAmountOut >= 2^248,validUntilSeconds >= 2^32. - Root-history boundary acceptance and rejection.
- Auth-policy registration-tree membership rejection when
leafPositiondoes not matchposeidon(AUTH_POLICY_DOMAIN, uint160(authorizingAddress), policyCommitment)for the witnessed(authVerifier, authDataCommitment, registrationBlinder); registration forgery (attacker submitting a victim asuser) yields a leaf bound tomsg.sender, failing membership. leafPositionrange rejection (leafPosition + 2^32MUST fail the Section 7.1 range constraint) and third-partyderegisterAuthPolicyrevert on theauthPolicyOwnercheck.- Auth-policy revocation-tree non-membership rejection after the revocation-root window passes; pre-revocation intents within
AUTH_POLICY_ROOT_HISTORY_BLOCKSremain spendable. - Cross-proof binding rejection: pool and auth proofs disagreeing on
(blindedAuthCommitment, transactionIntentDigest)MUST fail. - Registration recovery: losing a registration blinder makes that leaf unspendable, but the user can revoke the public
leafPositionand register a fresh policy. - Dummy-output constraint failures.
- ETH deposit.
- Token deposit.
- Deposit rejection for fee-on-transfer tokens.
- Transfer with two real inputs.
- Transfer with one real input and one phantom input.
- Withdrawal with change.
- Output binding locks over
noteBodyCommitment. - Final note commitment reconstruction from
noteBodyCommitmentand assigned leaf index. - Nullifier uniqueness for distinct final note commitments even when note semantic contents match.
- Reserved-flag-bit rejection.
- Locked-slot mismatch rejection.
Implementations SHOULD additionally test tree-capacity failure at the depth-32 note-commitment-tree boundary.
Implementations SHOULD also test the finalized-output-binding and nonce-replay cases:
- changing only execution constraints changes
transactionIntentDigestbut notintentReplayId - reusing the same nonce across otherwise distinct authorizations yields the same
intentReplayId - fresh nonce changes both
transactionIntentDigestandintentReplayIdwhen all other fields remain the same - a locked slot succeeds when
lockedOutputBinding{i} == poseidon(OUTPUT_BINDING_DOMAIN, noteBodyCommitment_i, outputNoteDataHash_i) - a locked slot fails if
noteBodyCommitment_ichanges whileoutputNoteDataHash_istays fixed - a locked slot fails if
outputNoteDataHash_ichanges whilenoteBodyCommitment_istays fixed - an unlocked slot accepts
lockedOutputBinding{i} = 0without requiring equality toposeidon(OUTPUT_BINDING_DOMAIN, noteBodyCommitment_i, outputNoteDataHash_i)
Security Considerations
Multi-Auth Security Boundary
Every active (address, authVerifier) pair is an independent spend-authorization path for notes bound to that address's registry namespace. Registering a weak auth verifier alongside a strong one widens the attack surface, but spending still also requires custody of ownerNullifierKey and the relevant proving material.
Auth Verifier Trust
A user who registers an auth policy at an authVerifier address trusts that address to correctly verify auth proofs. A malicious or buggy auth verifier can validate auth proofs that should fail, allowing whoever can construct such a proof to spend that user's notes. The compromise is bounded: only users who have registered an auth policy at that specific address are at risk, and only for spends that go through that auth verifier. Other auth policies registered by the same user (at other auth verifiers) are unaffected.
DoS via Root History
Prolonged congestion can cause proofs against stale roots to fail before submission. The note-commitment root history is a fixed-size circular buffer that advances on every transact and every deposit. Under sustained high throughput, users must submit proofs before the buffer wraps past their proven root.
Metadata Leakage
Deposits and withdrawals are public by design. Deposits reveal depositor, token, and amount. Private transfers keep token and amount private and reveal which authVerifier was used — and through it which auth method — but private registration (Section 6.4) keeps the user-to-verifier mapping off-chain, so the apparent anonymity set seen by an observer is every user with an active registration rather than only the registrants of the visible authVerifier. The actual sender set is a subset of that (users with an unrevoked leaf for this verifier under the accepted roots), but observers cannot collapse apparent to actual without breaking registrationBlinder. Output note data may leak metadata depending on the delivery scheme and wallet payload conventions in use.
Chain-Level Linkability of Self-Reshield Flows
A self-reshield flow — transact withdrawal to a public helper contract, public swap or other public execution, then deposit of the result back into the pool — is chain-level-linkable even though the reshielded note itself is private. The withdrawing EOA, the swap, and the deposit call are all public transactions attributable to the same initiator, and their composition is observable.
The privacy property this flow provides is post-swap anonymity: the reshielded note joins the general note anonymity set and its eventual spend is indistinguishable from any other private-note spend. The flow does not make the swap itself private, and it does not delink the initiator from the act of shielding. Any atomic external swap against a public venue has this property regardless of the shielded-pool design.
State Growth
The pool accumulates append-only state for note commitments, nullifiers, and intent replay IDs. These values cannot be safely pruned without breaking spend or replay protection.
Output Note Data Leakage and Sabotage
outputNoteData payloads are opaque and on-chain. Their size and structure can leak metadata: empty or variable-size dummy payloads can leak which outputs are real. A malicious sender, prover, or coordinator can also emit unusable outputNoteData and make note recovery fail. This cannot steal funds or redirect payment, but it can break recipient recovery. Wallet-layer or companion-standard delivery formats SHOULD use constant-size payloads to limit structural leakage.
Auth Policy Registry Liveness
The auth-policy registration tree has a circular-buffer root history of size AUTH_POLICY_REGISTRATION_ROOT_HISTORY_SIZE. Registrations advance it one slot at a time; provers must submit proofs against a root still within the buffer. The auth-policy revocation tree uses a block-based root history with window AUTH_POLICY_ROOT_HISTORY_BLOCKS. The block-based aging rule (at most one root history entry per block) prevents same-block churn from burning multiple history slots. An attacker churning revocations across blocks can fill the history with attacker-controlled roots, but doing so does not affect other users' ability to submit legitimate proofs against any revocation root still in the window. The buffer sizes are consensus-critical and fixed in this specification.
Auth-Policy Registration Hygiene
Losing (authVerifier, authDataCommitment, registrationBlinder) makes the leaf unspendable but does not affect fund access — notes bind to ownerNullifierKeyHash, not to any auth policy — so the user can re-register at a new leafPosition. deregisterAuthPolicy(leafPosition) requires only msg.sender and a public leafPosition, so an orphan leaf can be closed after recovering the position from AuthPolicyRegistered events. deregisterAuthPolicy is msg.sender-gated rather than blinder-nullified so any party that has seen registrationBlinder cannot grief-revoke a user's policy. nonce and blindingFactor MUST be drawn from a cryptographic RNG with at least 128 bits of effective entropy: low-entropy nonce allows a digest-preimage brute force; low-entropy blindingFactor plus a guessable authDataCommitment deanonymizes the registered auth data from blindedAuthCommitment.
noteSecret Reuse
Reusing noteSecret across notes does not by itself create nullifier collisions in this design because nullifiers are derived from final note commitments that include the assigned leaf index. It does, however, create linkability and degrades privacy. Wallets MUST avoid noteSecret reuse.
Deposits Do Not Enforce Registration On-Chain
The contract accepts opaque ownerCommitment values on deposit and does not prove or enforce on-chain that the recipient is registered. This is intentional: requiring on-chain recipient validation would either reveal the recipient address or reintroduce a proof on the deposit path. The standard address-based receive path therefore depends on senders resolving ownerNullifierKeyHash from the user registry off chain, with any delivery information obtained out of band or via a companion standard.
Unlocked Output Slots
If an authorization leaves an output slot unlocked (Section 9.11), the prover may choose that slot's outputNoteData and any otherwise-unpinned note details subject to the pool circuit's normal constraints. This cannot steal funds or override authorized payment fields, but malformed or unrecoverable outputNoteData can break recipient recovery. Authorizations that need finalized slot contents pinned SHOULD lock the corresponding outputBinding.
Pool Proof System Assumptions
Soundness rests on a one-time multi-party trusted-setup ceremony for the canonical pool circuit: at least one participant must honestly destroy their toxic-waste contribution. Verifier upgrades altering the pool circuit's R1CS shape MUST run a fresh ceremony. Under quantum adversaries, commitments and nullifiers (254-bit Poseidon2/BN254) sit at ≈2^111 BHT for the dominant note-commitment-tree multi-target preimage at depth-32 saturation, with nullifier collisions at the ≈2^84 BHT floor (DoS-only, second spend reverts). The outputNoteDataHash_i mod p reduction (Section 9.7) is bias-negligible. The pool proof system is classical Groth16 BN254 (future PQ migration in Rationale).
Copyright
Copyright and related rights waived via CC0.