This EIP introduces protocol-level private ETH and compatible ERC-20 transfers with public deposits and withdrawals, implemented as a system contract with a companion proof-verification precompile. Deposits insert notes for hidden owner identifiers through a proof-free contract path. Private transfers and withdrawals spend existing notes through a split-proof architecture that separates protocol invariants from permissionless authentication. The same system contract also exposes an optional delivery-key registry for standardized on-chain note-delivery key discovery. The system contract has no on-chain upgrade mechanism and can only be replaced by a hard fork.
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, registries, and one baseline note-delivery scheme. The delivery-key registry is optional and only standardizes one on-chain discovery path. End-to-end transaction privacy still requires complementary infrastructure (mempool encryption, network-layer anonymity, wallet integration) that is out of scope.
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, delivery-key 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 proof verification precompile, 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 credentials without publishing the user-to-verifier mapping.
An optional delivery-key registry binding each address to one active registered note-delivery endpoint.
A post-quantum baseline for note delivery via scheme-1 ML-KEM-768. Auth verifiers are user-selected and outside this baseline.
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 noteBodyCommitment plus 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.
Proving infrastructure: Infrastructure that generates zero-knowledge proofs. May be first-party (local machine, self-hosted server) or third-party (a proving service). See Section 4.1.
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 via the proof verification precompile.
Auth circuit: A permissionless circuit that handles authentication and intent parsing. Outputs [blindedAuthCommitment, transactionIntentDigest]. Each auth circuit has a corresponding authVerifier Solidity 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 its leafPosition. See Sections 5.2 and 6.4.
authDataCommitment: The opaque per-credential commitment a user registers in the auth-policy registration tree. The auth circuit derives this value from the user’s authentication credential.
registrationBlinder: Per-registration user secret hashed into policyCommitment to keep the (user, authVerifier) mapping unrecoverable from public tree state. Derivable from the wallet seed; stays witness-only.
policyCommitment: Opaque uint256 Poseidon2 digest submitted to registerAuthPolicy (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 credential.
blindingFactor: Fresh per-tx random value used as preimage input to blindedAuthCommitment. Authenticated by the auth-proof signature but excluded from transactionIntentDigest.
Transaction intent digest: The canonical digest of the contemplated private-note spend. It includes the signer-authenticated transaction fields, the chosen authVerifier, and a random nonce. The auth circuit authenticates this digest from the signed 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 transact call. 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 address to (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 transact outputs it is deterministically derived from noteSecretSeed, intentReplayId, and outputIndex. In deposits it is chosen or derived by the depositing wallet and communicated to the recipient through outputNoteData or out-of-band coordination.
ownerCommitment: poseidon(OWNER_COMMITMENT_DOMAIN, ownerNullifierKeyHash, noteSecret). The owner-side note commitment. Hides ownerNullifierKeyHash and noteSecret from on-chain observers while letting the contract incorporate them into the final noteCommitment.
leafIndex: The final note-tree leaf index assigned by the contract when the note is inserted.
Delivery endpoint: A public (schemeId, keyBytes) pair registered by an address for note delivery in the delivery-key registry. schemeId = 0 denotes no active registered endpoint in this registry. The contract stores endpoints opaquely and does not validate that keyBytes are well-formed for the selected scheme.
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. Section 13 defines the scheme-1 interpretation. Delivery may also be coordinated out of band.
Output binding: poseidon(OUTPUT_BINDING_DOMAIN, noteBodyCommitment, outputNoteDataHash). This binds one emitted semantic note commitment to one output-note-data hash for signer-authenticated finalized-output locking.
Execution constraints: The private signed fields executionConstraintsFlags and lockedOutputBinding0/1/2. They optionally bind finalized output slots.
authorizingAddress: The private signer-controlled 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 ownerNullifierKeyHash must 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. 0 means the signer 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 enforce feeNoteRecipientAddress == feeRecipientAddress. If feeRecipientAddress == 0 and feeAmount > 0, the prover chooses feeNoteRecipientAddress at proof generation time.
feeAmount: A private witness in the transaction intent digest. The optional private fee paid through output slot 2. 0 means no fee.
nonce: A private signed random uint256 value used for replay protection and transaction-intent-digest privacy in transact.
executionConstraintsFlags: A private signed bitmask selecting which finalized-output slots are locked by the signer.
lockedOutputBinding0/1/2: Private signed uint256 values that optionally lock outputBinding0/1/2 for 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 from block.timestamp to validUntilSeconds, 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 enforces amount == 0 for dummy outputs, preventing value extraction regardless of preimage knowledge.
TRANSFER_OP = 0 — operation kind for private transfers.
WITHDRAWAL_OP = 1 — operation kind for withdrawals.
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 pool verifier 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 by invoking the proof verification precompile (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 — and intent parsing — computing the transaction intent digest over transaction fields, the chosen authVerifier, and any signer-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 via the precompile, 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 proof verification and auth verifier dispatch
System contract
Yes
Credential signature verification, 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). The signer 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. Section 13 defines the registry lookup and the scheme-1 interpretation.
4.1 Proving Modes
Proof generation can be delegated to a third party without granting spending authority. This section uses first-party and third-party to describe who is trusted to operate the prover; local and remote describe where computation runs. A self-hosted cloud server is first-party but remote.
Two proving configurations are supported for transact:
First-party proving. The user controls the proving infrastructure — a local machine or self-hosted server. No third party sees transaction details beyond what is visible on-chain. Requires client software that handles ownerNullifierKey, noteSecretSeed, coin selection, witness construction, and note-delivery key lookup plus any supported delivery schemes.
Third-party proving. The user signs an authorization and delegates proof generation to a specialized proving service. The prover learns all transaction details and retains discretion over coin selection and registry root selection within the valid history window. It cannot forge unauthorized operations, redirect payments, or extract funds — these properties are enforced by the proof system regardless of prover behavior. If the signer leaves a slot unlocked, a malicious prover can still choose unusable outputNoteData or mutate that slot’s finalized output at proving time; if the signer locks a slot via lockedOutputBinding0/1/2, the prover cannot mutate that slot after signing.
Deposits are not proof-backed. A first-party deposit is constructed directly by the sender’s wallet or software. A third party may still act as a coordinator for deposit construction or broadcast, but that is a delivery/construction choice, not a proving mode.
On-chain
Third-party prover / coordinator
Tx occurred
yes
yes
Token
deposits and withdrawals
yes
Amount
deposits and withdrawals
yes
Fee amount
no
yes
Fee recipient
no
yes
Sender
deposits and transact-side msg.sender
yes
Recipient
withdrawals
yes
Which notes spent
no
yes
Auth method used
yes (authVerifier is a public input)
yes
If feeRecipientAddress == 0 and feeAmount > 0, the prover chooses feeNoteRecipientAddress at proof time. The registered authDataCommitment is hidden behind per-transaction blinding (Section 9.1). outputNoteData{i} payloads are on-chain; their size and structure may leak metadata depending on the delivery scheme.
Users MUST back up ownerNullifierKey (loss = permanent fund loss) and either noteSecretSeed or note plaintext including noteSecret. Users relying on delivery keys for note recovery SHOULD also retain the corresponding delivery private keys until all notes encrypted to them have been recovered.
A third-party prover learns ownerNullifierKey permanently and can derive future noteSecrets from the current noteSecretSeed; after rotateNoteSecretSeed runs and stale user roots expire, the old prover can no longer derive secrets for that address’s future transactions. Delivery keys are wallet-layer material; rotating one does not affect note ownership or proof validity, but the old delivery private key may still be needed to recover notes created before the rotation.
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 the account object in the shielded-pool state dump at SHIELDED_POOL_ADDRESS. This state dump is the canonical shielded-pool activation artifact. The installer used to generate it is non-normative tooling and is not part of consensus.
The code at SHIELDED_POOL_ADDRESS can 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 canonical Groth16 verification key for the pool circuit is pinned by pool_vk.bin and pool_vk.sha256; the verification-key byte layout, public-input vector convention, and pairing equation are normative in Section 5.5. The system contract holds no verification key; pool-proof verification is performed by the precompile.
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 (tokenAddress is inside the note commitment). The contract MUST revert if nextLeafIndex + 3 > 2^32 before a transact insertion or if nextLeafIndex + 1 > 2^32 before a deposit insertion.
Note commitment root history — circular buffer (size: NOTE_COMMITMENT_ROOT_HISTORY_SIZE, consensus-critical). On each transact and each deposit, 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).
Delivery key registry — mapping(address => DeliveryEndpoint) storing one active public registered delivery endpoint (schemeId, keyBytes) per registered address. This registry is optional, is not Merkleized, is not referenced by any circuit, has no root history, and does not affect proof validity.
Auth-policy registration tree — depth-32 append-only Poseidon Merkle tree, with nextLeafIndex tracked. Empty leaf = 0. The contract MUST revert if nextLeafIndex + 1 > 2^32 before a registration insertion.
Auth-policy registration root history — circular buffer (size: AUTH_POLICY_REGISTRATION_ROOT_HISTORY_SIZE, consensus-critical). On each registerAuthPolicy, 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 gate deregisterAuthPolicy so only the registering address can revoke a given leaf. address(0) denotes an unused position.
ownerNullifierKeyHash index — mapping(uint256 ownerNullifierKeyHash => address user) mapping each registered ownerNullifierKeyHash to the address that owns it. Used to enforce global ownerNullifierKeyHash uniqueness on registration. address(0) denotes an unregistered ownerNullifierKeyHash.
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.
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.
setDeliveryKey is called by msg.sender to set or replace the caller’s active registered delivery endpoint. The caller MUST already have a user-registry entry. schemeId MUST be nonzero and keyBytes.length MUST be nonzero. The contract stores keyBytes opaquely and MUST NOT validate that they are well-formed for the selected scheme.
removeDeliveryKey is called by msg.sender to clear the caller’s active registered delivery endpoint. The caller MUST already have a user-registry entry. The contract MUST revert if no delivery endpoint is currently set.
getDeliveryKey returns the active registered delivery endpoint for user, or (0, "") if none is registered in this registry.
Delivery-key changes do not affect in-flight proofs, proof validity, or any root-history acceptance rule.
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 == 0 or policyCommitment >= p (Section 3.5).
Computes leafValue = poseidon(AUTH_POLICY_DOMAIN, uint160(msg.sender), policyCommitment). This on-chain hash binds the registration’s user field to msg.sender by 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 leafValue at the next leafPosition in the auth-policy registration tree.
Writes authPolicyOwner[leafPosition] = msg.sender atomically 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 ownership gate both (a) prevents any other address, including a third-party prover used for delegated proving, from revoking the leaf, and (b) makes registrationBlinder unnecessary for revocation authentication — so the blinder never leaves the wallet.
MUST revert if leafPosition >= 2^32.
MUST revert if the revocation-tree leaf at leafPosition is already 1 (already revoked).
Writes 1 at the revocation-tree leaf keyed by leafPosition and 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).
This is a submission-window bound, not a measure of time since signing.
Check note-commitment root. Require noteCommitmentRoot equals the current note-commitment root or is in the note-commitment root history.
Check registry root. Require registryRoot equals the current user-registry root or is in the user-registry root history. registryRoot MUST be nonzero.
Check auth-policy registration root. Require authPolicyRegistrationRoot equals the current auth-policy registration root or is in the registration root history. authPolicyRegistrationRoot MUST be nonzero.
Check auth-policy revocation root. Require authPolicyRevocationRoot equals the current auth-policy revocation root or is in the revocation root history. authPolicyRevocationRoot MUST 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, and authVerifier < 2^160. Values >= 2^160 alias when interpreted as EVM addresses.
Require validUntilSeconds < 2^32.
Require executionChainId < 2^32.
Require authVerifier != 0.
Verify the pool proof. Invoke PROOF_VERIFY_PRECOMPILE_ADDRESS (Section 5.5) with abi.encode(poolProof, publicInputs) and revert unless it returns exactly 32 bytes decoding to uint256(1).
Verify the auth proof via the auth verifier. Construct authPublicInputs = abi.encode(blindedAuthCommitment, transactionIntentDigest). Invoke IAuthVerifier(address(uint160(authVerifier))).verifyAuth(authPublicInputs, authProof) via staticcall (Section 12). MUST revert if the staticcall reverts, returns non-32 bytes, or returns false.
Mark nullifiers spent. Require both nullifiers are unspent; then mark them spent.
Mark intent replay ID used. Require intentReplayId is 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.transact is non-payable; any msg.value > 0 reverts on entry. Exactly one of the following two branches MUST match:
Withdrawal (publicAmountOut > 0)
Require publicRecipientAddress != 0.
If publicTokenAddress == 0 (ETH): perform a low-level CALL to address(uint160(publicRecipientAddress)) with value publicAmountOut, empty calldata, and all remaining gas; require success.
If publicTokenAddress != 0 (ERC-20): execute transfer(publicRecipientAddress, publicAmountOut) and require success.
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). The proof verification precompile 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 via staticcall, MUST not revert, and MUST return exactly 32 bytes.
transferFrom(msg.sender, address(this), amount) and transfer(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 false return 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).
Receive public assets.
If token == address(0) (ETH): require msg.value == amount.
If token != address(0) (ERC-20): require msg.value == 0. Record balBefore = balanceOf(address(this)). Execute transferFrom(msg.sender, address(this), amount) and require success. Require balanceOf(address(this)) - balBefore == amount.
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 ownerNullifierKeyHash from the user registry and any delivery endpoint from the delivery-key registry,
Opaque owner-side commitments MAY also be coordinated out of band.
5.5 Proof Verification Precompile
A dedicated precompile embeds the canonical Groth16/BN254 verification key and verifies proofs using the standard Groth16 verification equation (EIP-196 / EIP-197 point encodings). Replacing the VK requires a hard fork.
Input: abi.encode(proof, publicInputs) — 256-byte Groth16/BN254 proof (A, B, C), plus the 21-field PublicInputs struct of Section 5.3 ABI-encoded as 21 uint256 values in declaration order.
Output: 32 bytes; uint256(1) on success, uint256(0) on any failure. MUST NOT revert.
Failure modes: malformed proof, any public input >= p (Section 3.5), any G1/G2 point off-curve or not in the prime-order subgroup, pairing-equation failure.
Gas cost: 1_000_000.
Verification key: pinned by pool_vk.bin and pool_vk.sha256, in the standard Groth16/BN254 layout (α ∈ G1; β, γ, δ ∈ G2; IC[0..21] ∈ G1). Clients MUST verify the embedded VK against the pinned digest at install time.
6. Registries
6.1 User Registry
The shielded pool maintains a Poseidon Merkle tree mapping:
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, and optionally the recipient’s delivery endpoint, 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 and delivery data 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, the active delivery endpoint via getDeliveryKey, and the current accepted roots via getCurrentRoots.
6.2 Registration Methods
The contract MUST provide:
registerUser(ownerNullifierKeyHash, noteSecretSeedHash) — callable by msg.sender. MUST revert if the address is already registered.
registerUser(ownerNullifierKeyHash, noteSecretSeedHash, schemeId, keyBytes) — callable by msg.sender. MUST revert if the address is already registered. This overload also initializes the caller’s registered delivery endpoint.
rotateNoteSecretSeed(newNoteSecretSeedHash) — callable by msg.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 both registerUser overloads, 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.
Both registerUser overloads 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’s noteSecretSeed burn nullifiers for that sender’s dummy outputs via the Section 9.3 registry binding. Rejecting the value at registration makes dummy notes structurally unspendable.
Both registerUser overloads 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).
The 4-argument registerUser overload MUST additionally require schemeId != 0 and keyBytes.length != 0, write the caller’s registered delivery endpoint atomically with the user-registry entry, and emit both UserRegistered and DeliveryKeySet.
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. Third-party provers used for delegated proving cannot revoke a user’s registration because msg.sender will not match the stored owner.
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-signed authorization. If a user re-registers the same verifier and credential 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 authVerifier Solidity contract per Section 12.
Users register their credentials via registerAuthPolicy(policyCommitment) with policyCommitment computed over the new authVerifier. 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 authenticate all intent-digest fields, including authVerifier and nonce, and additionally authenticate blindingFactor via signature over the full signed intent struct even though blindingFactor is excluded from transactionIntentDigest. Companion ERCs MUST treat each authenticated value as a single BN254 scalar field element per Section 3.5. Because authVerifier is authenticated 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, setDeliveryKey, removeDeliveryKey, 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.
6.5 Delivery Key Registry
The system contract maintains a public delivery-key registry mapping:
address → (schemeId, keyBytes)
Each address has at most one active registered delivery endpoint. schemeId = 0 denotes no active registered endpoint in this registry. This registry is optional, is not Merkleized, is not referenced by any circuit, and does not affect proof validity or root histories.
The registry exists solely as a standardized on-chain discovery surface for the party constructing outputNoteData for an address-based receive flow. In first-party deposit construction this is the sender’s wallet or software; in delegated proving it may be the prover or coordinator.
If getDeliveryKey(recipient) == (0, ""), this EIP provides no on-chain delivery metadata for that recipient. The deposit can still be constructed because outputNoteData is protocol-opaque, but note delivery must be coordinated out of band.
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.
leafPosition MUST be constrained to < 2^32, matching the depth-32 registration and revocation trees.
Contract-side, the pool MUST reject:
publicRecipientAddress, publicTokenAddress, or authVerifier values >= 2^160 before interpreting them as EVM addresses in transact.
publicAmountOut >= 2^248 in transact and deposit amount >= 2^248 in deposit.
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.
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.
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:
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 ownerNullifierKeyHash and optionally the recipient’s delivery endpoint,
sender constructs ownerCommitment and outputNoteData,
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,
publicTokenAddress equal to the withdrawn ERC-20 contract address, or 0 for 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 authorizingAddress against registryRoot and extract the sender’s ownerNullifierKeyHash and noteSecretSeedHash,
enforce the range constraint leafPosition < 2^32 (Section 7.1),
prove auth-policy registration-tree membership for the witnessed leafPosition against authPolicyRegistrationRoot, where the opened leaf equals poseidon(AUTH_POLICY_DOMAIN, uint160(authorizingAddress), policyCommitment) with policyCommitment = poseidon(POLICY_COMMITMENT_DOMAIN, uint160(authVerifier), authDataCommitment, registrationBlinder),
prove auth-policy revocation-tree non-membership at key leafPosition against authPolicyRevocationRoot (the leaf at that position MUST equal 0),
recompute blindedAuthCommitment = poseidon(BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor) and enforce equality with public input blindedAuthCommitment,
recompute transactionIntentDigest per Section 9.10 and enforce equality with public input transactionIntentDigest,
derive intentReplayId per Section 9.8 and enforce that the derived value equals public input intentReplayId,
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 a credential, an authorization signature by that credential that authenticates every transactionIntentDigest input (Section 9.10) plus blindingFactor, and the canonical authDataCommitment derivation from that credential, such that:
the intent’s authVerifier field equals the Solidity address of the verifier contract handling the verifyAuth call. 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 ownerNullifierKey nor noteSecretSeed appears 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, and nullifier,
the circuit MUST enforce that the recomputed noteCommitment equals the committed leaf being opened.
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
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.
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:
output slot 0 is the recipient payment: isDummy_0 == 0, ownerNullifierKeyHash_0 MUST equal registryOwnerNullifierKeyHash(recipientAddress), amount_0 MUST equal the authorized private amount, and tokenAddress_0 MUST equal the authorized private token.
output slot 1 is sender change or dummy: if isDummy_1 == 0, ownerNullifierKeyHash_1 MUST equal senderOwnerNullifierKeyHash.
output slot 2 is a fee note or dummy.
Withdrawal
output slot 0 is sender change or dummy: if isDummy_0 == 0, ownerNullifierKeyHash_0 MUST equal senderOwnerNullifierKeyHash.
output slot 1 MUST be dummy.
output slot 2 is a fee note or dummy.
For output slot 2 specifically:
feeAmount == 0 iff output slot 2 is dummy, and then feeRecipientAddress == 0,
feeAmount > 0 iff output slot 2 is real,
if slot 2 is real, amount_2 MUST equal feeAmount,
if feeAmount == 0, feeNoteRecipientAddress == 0,
if slot 2 is real, feeNoteRecipientAddress MUST be nonzero,
if feeAmount > 0 and feeRecipientAddress != 0, then the circuit MUST enforce feeNoteRecipientAddress == feeRecipientAddress,
if slot 2 is real, ownerNullifierKeyHash_2 MUST equal registryOwnerNullifierKeyHash(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 ownerNullifierKeyHash and noteSecretSeedHash,
if feeAmount != 0, prove feeNoteRecipientAddress has a user-registry entry and extract its ownerNullifierKeyHash,
prove registration-tree leaf membership at the witnessed leafPosition and revocation-tree non-membership at the same leafPosition.
Withdrawal
prove the sender has a user-registry entry and extract sender ownerNullifierKeyHash and noteSecretSeedHash,
if feeAmount != 0, prove feeNoteRecipientAddress has a user-registry entry and extract its ownerNullifierKeyHash,
prove registration-tree leaf membership at the witnessed leafPosition and revocation-tree non-membership at the same leafPosition.
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.
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 signing. The final inserted noteCommitment includes a contract-assigned leaf index and is therefore not itself the signer-lock target.
The pool and auth circuits do not validate encryption scheme semantics or delivery format. Section 13 defines the registry lookup and the interpretation for scheme ID 1.
9.8 Intent Replay ID
All private-note spends use the same intent replay ID derivation:
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.
nonce MUST 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_OP
publicAmountOut == 0 → TRANSFER_OP
Normative execution-field binding
Withdrawal
recipientAddress == publicRecipientAddress
amount == publicAmountOut
tokenAddress == publicTokenAddress
validUntilSeconds == public input
executionChainId == block.chainid (checked by contract)
feeRecipientAddress and feeAmount are private witnesses bound through intent-digest computation.
Transfer
recipientAddress, amount, feeRecipientAddress, and feeAmount are private, bound through intent-digest computation, output constraints, and value conservation.
tokenAddress is private, bound through token consistency (Section 9.9).
validUntilSeconds == public input
executionChainId == block.chainid (checked by contract)
publicRecipientAddress == 0
publicAmountOut == 0
publicTokenAddress == 0
9.11 Execution Constraints
Execution constraints let the signer optionally bind finalized output slots without changing the nonce-based replay domain. The signed fields executionConstraintsFlags, lockedOutputBinding0, lockedOutputBinding1, and lockedOutputBinding2 are inputs to transactionIntentDigest (Section 9.10).
executionConstraintsFlags < 2^32. Any bit other than LOCK_OUTPUT_BINDING_0, LOCK_OUTPUT_BINDING_1, LOCK_OUTPUT_BINDING_2 MUST cause proof failure.
For each i ∈ {0, 1, 2}: if executionConstraintsFlags & LOCK_OUTPUT_BINDING_i != 0, then lockedOutputBinding_i == outputBinding_i; otherwise lockedOutputBinding_i == 0.
10. Public Inputs
The proof verification precompile’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.
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 against block.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 signed/authenticated 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 precompile 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).
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.
publicInputs is exactly abi.encode(blindedAuthCommitment, transactionIntentDigest), where both values are uint256.
proof is 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 and Delivery Keys
Deposits and private-note spends both emit opaque outputNoteData, but they differ in how strongly the payload is bound:
In transact, outputNoteDataHash is a public input and is therefore proof-bound.
In deposit, the contract emits outputNoteData opaquely and does not bind it to a proof.
The protocol does not require outputNoteData to carry a scheme tag or version. Recipients MAY therefore need to attempt recovery with their current delivery private key and any retained prior delivery keys and supported schemes.
Implementations SHOULD use constant-size real and dummy payloads within each supported scheme to reduce structural leakage.
13.1 Scheme IDs and Support Requirements
The delivery-key registry uses the following scheme-ID namespace:
0 — unset / invalid
1 — ML-KEM-768
all other nonzero values — reserved for future assignment or private use
Implementations claiming general interoperability with this EIP MUST support scheme 1. They MAY support additional schemes.
Additional nonzero scheme IDs MAY be assigned by later standards. Such standards define the meaning of keyBytes and any payload interpretation for their assigned IDs.
The contract MUST accept any nonzero schemeId in setDeliveryKey; it does not maintain an on-chain allowlist and MUST NOT validate that keyBytes are well-formed for the selected scheme. A user can therefore publish malformed key material and break their own receive path.
13.2 Scheme 1A: transact Output Payloads
For ordinary transact outputs, scheme 1 plaintext is the four note fields below, each encoded as a 32-byte big-endian word:
outputNoteData for this subtype MUST be exactly 1232 bytes and encoded as enc || ciphertext || tag, where:
enc is the first 1088 bytes and is the raw ML-KEM-768 ciphertext,
ciphertext is the next 128 bytes and is the AES-256-GCM ciphertext of the 128-byte plaintext above,
tag is the final 16 bytes and is the AES-256-GCM authentication tag.
The recipient decapsulates, decrypts, recomputes ownerCommitment, noteBodyCommitment, and then recomputes the final noteCommitment using the event’s leafIndex0 + outputIndex.
13.3 Scheme 1B: Deposit Payloads
For deposits, the contract does not know noteSecret. The deposit payload therefore carries only the private fields not published in the deposit event:
ownerNullifierKeyHash || noteSecret
outputNoteData for this subtype MUST be exactly 1168 bytes and encoded as enc || ciphertext || tag, where:
enc is the first 1088 bytes and is the raw ML-KEM-768 ciphertext,
ciphertext is the next 64 bytes and is the AES-256-GCM ciphertext of the 64-byte plaintext above,
tag is the final 16 bytes and is the AES-256-GCM authentication tag.
The recipient combines:
ownerNullifierKeyHash and noteSecret from the decrypted payload,
amount, tokenAddress, and leafIndex from the ShieldedPoolDeposit event,
to reconstruct ownerCommitment, noteBodyCommitment, and the final noteCommitment.
13.4 ML-KEM-768 Details
For scheme 1, keyBytes MUST be a raw 1184-byte ML-KEM-768 encapsulation key. This EIP pins scheme 1 to FIPS 203 final, ML-KEM-768 parameter set, for key generation, encapsulation, and decapsulation. Implementations MUST reject malformed inputs as required by FIPS 203. Scheme 1 is frozen by FIPS 203 final plus the normative vectors in the scheme 1 vector asset; later standards do not alter EIP-8182 scheme 1.
The sender/prover encapsulates to the registered ML-KEM-768 public key, obtaining enc plus the 32-byte shared secret, and then derives the AEAD key and nonce from that shared secret with HKDF-SHA256:
The recipient decapsulates enc with the corresponding ML-KEM-768 decapsulation key, derives the same AEAD key and nonce, verifies tag, decrypts ciphertext, recomputes the note commitment, and MUST reject on mismatch.
When output slot 2 is used for fee compensation, the actual recipient of that note — represented in the circuit by feeNoteRecipientAddress, and either designated by feeRecipientAddress or prover-chosen when feeRecipientAddress == 0 — SHOULD receive enough offchain fee-note data to recompute noteBodyCommitment2 and, if outputNoteDataHash2 is also known pre-broadcast, outputBinding2 before broadcasting the transaction. The final noteCommitment2 cannot be known before execution because it is sealed with the contract-assigned leaf index. Because the protocol does not validate payload semantics, a fee recipient cannot safely rely on opaque outputNoteData2 bytes alone as proof of payment.
14. Example ECDSA Companion Standard (Non-Normative)
This section sketches an example auth circuit for ECDSA/secp256k1 authorization using EIP-712 typed data signing. The companion authVerifier Solidity contract verifies proofs of this circuit per the Section 12 interface.
The user signs an EIP-712 typed struct containing the intent fields:
The domain binds executionChainId and the pool address without repeating them in the struct.
The auth circuit:
Computes the EIP-712 signing hash from the struct and domain.
Verifies the ECDSA signature against a witnessed secp256k1 public key (ecdsaPubKeyX, ecdsaPubKeyY) (each a 32-byte big-endian value), and derives authorizingAddress = keccak256(ecdsaPubKeyX || ecdsaPubKeyY)[12:].
Computes transactionIntentDigest per Section 9.10 using the signed fields, the executionChainId taken from the EIP-712 domain, and authVerifier == address(this).
Computes authDataCommitment = poseidon(xHi, xLo, yHi, yLo) where (xHi, xLo) and (yHi, yLo) are the high and low 16 bytes of ecdsaPubKeyX and ecdsaPubKeyY interpreted as big-endian uint128 values, then blindedAuthCommitment = poseidon(BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor).
Outputs [blindedAuthCommitment, transactionIntentDigest]. The user registers authDataCommitment in registerAuthPolicy once per (verifier, key) pair.
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 malicious pool precompile implementation could drain the entire pool, so pool relation upgrades require the same social consensus as any other protocol change. Auth circuits and their auth verifier contracts are permissionless because the pool circuit independently enforces all pool-critical invariants.
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
Pool invariants live in the fork-managed pool circuit, verified by the proof verification precompile (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 signed fields, and blindingFactor hides the registered credential in blindedAuthCommitment. Both are authenticated by the auth-proof signature.
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.
Future PQ Migration
Groth16 over BN254 is not post-quantum secure. The proof system is fork-swappable via the proof verification precompile, and the on-chain state schema (tree shapes, domain tags, preimage layouts, public-input ABI, 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 if the future prover can prove the BN254-Poseidon2 relation.
Hidden Owner IDs with Address-Scoped UX
Users still share addresses and ENS names. Those addresses remain the canonical namespace for auth policies, delivery endpoints, 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, the prover is not fully trusted to choose output secrets freely: unconstrained noteSecret would give a third-party 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 signer’s registered seed, the signer’s chosen 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.
Specialized Proving and Wallet Compatibility
The protocol supports non-custodial proof delegation for spending existing notes. A third-party prover can help create a valid proof without needing to control the public withdrawal recipient or the output note-delivery path.
Post-Quantum Delivery Baseline
Delivery ciphertexts are public and permanent, so the relevant threat model is harvest-now-decrypt-later. A pure classical KEM does not survive it. A classical/post-quantum hybrid’s confidentiality reduces to its post-quantum component under the same threat model, and hybrid anonymity has only been established under the assumption that both components are anonymous — an assumption that fails post-quantum. Hybrid anonymity may still hold in that case, but no proof is available, and a privacy protocol should not rest on an unproven property. Scheme 1 therefore uses ML-KEM-768 alone. ML-KEM-768 is a NIST-standardized post-quantum KEM (FIPS 203) and addresses the harvest-now-decrypt-later threat at the layer where it actually applies. Auth verifiers are user-selected and outside this baseline. The pool proof system is classical Groth16 BN254; future PQ migration of the proof system is discussed in “Future PQ Migration” above and does not affect note delivery.
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 user designates the fee recipient in the signed intent 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, a new pool proof relation, and the proof verification precompile (Section 5.5), 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 verification key (pool_vk.bin) loads and its SHA-256 digest matches pool_vk.sha256.
Proof verification precompile accepts a valid Groth16 pool proof with the pinned VK and 21-field public-input vector, and rejects malformed encodings, non-canonical public inputs (>= p), or pairing failure; pool-proof rejection also when authVerifier == 0, >= 2^160, or has no deployed code.
User registration, seed rotation, delivery-key registration, auth-policy registration, and auth-policy deregistration; duplicate ownerNullifierKeyHash rejection.
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 leafPosition does not match poseidon(AUTH_POLICY_DOMAIN, uint160(authorizingAddress), policyCommitment) for the witnessed (authVerifier, authDataCommitment, registrationBlinder); registration forgery (attacker submitting a victim as user) yields a leaf bound to msg.sender, failing membership.
leafPosition range rejection (leafPosition + 2^32 MUST fail the Section 7.1 range constraint) and third-party deregisterAuthPolicy revert on the authPolicyOwner check.
Auth-policy revocation-tree non-membership rejection after the revocation-root window passes; pre-revocation intents within AUTH_POLICY_ROOT_HISTORY_BLOCKS remain 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 leafPosition and 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 noteBodyCommitment and assigned leaf index.
Nullifier uniqueness for distinct final note commitments even when note semantic contents match.
Deposit payload recovery for scheme 1B.
Ordinary transact payload recovery for scheme 1A.
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 transactionIntentDigest but not intentReplayId
reusing the same nonce across otherwise distinct authorizations yields the same intentReplayId
fresh nonce changes both transactionIntentDigest and intentReplayId when 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_i changes while outputNoteDataHash_i stays fixed
a locked slot fails if outputNoteDataHash_i changes while noteBodyCommitment_i stays fixed
an unlocked slot accepts lockedOutputBinding{i} = 0 without requiring equality to poseidon(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
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.
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 a third-party prover (which sees 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 credential from blindedAuthCommitment.
Third-Party Prover Residual Visibility
A third-party prover permanently learns ownerNullifierKey and can monitor spends of previously known notes indefinitely. It also learns the current noteSecretSeed; rotating it via rotateNoteSecretSeed cuts off future note-secret derivation after stale user roots expire. Delegated pool proving also reveals the auth-policy opening used for that proof, including authVerifier, authDataCommitment, registrationBlinder, and leafPosition.
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 and delivery data from the registry off chain.
Third-Party Prover Delivery Sabotage
If the signer leaves output-binding locks unset, a remote prover with full witness can still emit unusable outputNoteData or otherwise mutate unlocked finalized outputs while producing a valid proof. This cannot steal funds or redirect payment, but it can make note recovery fail. If the signer sets an output-binding lock for a slot, the prover cannot change that slot’s note-body commitment or payload bytes after signing. The final inserted noteCommitment is sealed by the contract from the locked noteBodyCommitment plus the contract-assigned leaf index and is therefore also pinned implicitly, but the lock target itself is the body commitment. This protects finalized-authorization flows only; it does not protect a blind signer from a malicious coordinator that assembled a bad finalized plan before signing. Additional higher-assurance mitigations include wallet-controlled finalization/broadcast, TEE-attested proving, MPC or witness-splitting systems that prevent any one prover from constructing an alternate proof, or future companion standards for richer authenticated execution binding. These mitigations are not standardized by this EIP.
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. ML-KEM-768 (scheme 1) is PQ; the pool proof system is classical Groth16 BN254 (future PQ migration in Rationale).
Tom Lehman (@RogerPodacter), "EIP-8182: Private ETH and ERC-20 Transfers [DRAFT]," Ethereum Improvement Proposals, no. 8182, March 2026. Available: https://eips.ethereum.org/EIPS/eip-8182.