This EIP introduces protocol-level private ETH and ERC-20 transfers with public deposits and withdrawals, implemented as a system contract with a companion proof-verification precompile. A recursive proof architecture separates protocol invariants enforced by a hard-fork-managed outer circuit from permissionless inner authentication circuits, allowing users to choose compatible authentication methods — such as ECDSA, passkeys, or multisig — without requiring a hard fork for each new auth method. 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 ERC-20 tokens and supports private transfers to ordinary Ethereum addresses. 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 (e.g., note commitment tree, nullifier set, intent-nullifier set, user registry, delivery-key registry).
A hard-fork-only upgrade model: the system contract’s code can only be replaced by a hard fork; there is no proxy or admin function.
A recursive proof composition separating auth (permissionless inner circuits) from protocol invariants (hard-fork-managed outer circuit).
An auth policy registry binding (address, auth-method) pairs to credentials, supporting multiple auth methods per address.
An optional delivery-key registry binding each address to one active registered note-delivery endpoint.
A public-input interface for proofs and required contract execution checks.
A per-note label tracking deposit lineage.
A proof verification precompile for gas-feasible proof verification.
These components are presented as a single EIP because they share state and form a single deployment unit.
App-level policy (e.g., compliance wrappers, selective-disclosure protocols, fees) is out of scope for the base contract and MAY be implemented by wrapper contracts.
2. Terminology
Note: A shielded UTXO-like object represented on-chain by a commitment.
Commitment: A Poseidon hash committing to a note’s fields.
Nullifier: A value published when spending a note to prevent double-spends.
Label: A cryptographic lineage tag. For single-origin notes, it traces the note back to its original deposit. For mixed-origin notes, a fixed sentinel (MIXED_LABEL) indicating provenance was lost at a merge point.
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.
Outer circuit: The hard-fork-managed circuit that enforces protocol invariants: value conservation, nullifiers, Merkle membership, deterministic output randomness, inner proof verification, and auth policy registry checks.
Inner circuit: A permissionless circuit that handles authentication and intent parsing. Outputs [authorizingAddress, authDataCommitment, policyVersion, intentDigest]. See Section 9.1.
Auth policy: A binding of auth program (inner circuit verification key) and credentials in the auth policy registry. Each address may have multiple auth policies, one per inner circuit. See Sections 5.2 and 6.4.
Canonical intent digest: A Poseidon hash over the transaction fields the user authorized. Computed independently by both inner and outer circuits; equality is enforced.
policyVersion: A monotonically increasing counter per (address, innerVkHash) pair. Authenticated by the inner circuit’s signed artifact. See Section 6.4.
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 (nullifierKeyHash, outputSecretHash). Leaf format: poseidon(USER_REGISTRY_LEAF_DOMAIN, uint160(user), nullifierKeyHash, outputSecretHash) (Section 3.4).
Output secret: A rotatable secret committed in the user registry as outputSecretHash = poseidon(OUTPUT_SECRET_DOMAIN, outputSecret). Used only for deterministic output randomness, not for nullifier derivation or wallet-layer note delivery.
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 15 defines the baseline scheme interpretation for ID 1; later standards MAY define additional schemes. Delivery may also be coordinated out of band.
depositorAddress: Public input. The deposit payer’s Ethereum address; msg.sender must equal it. Nonzero selects deposit mode.
recipientAddress: Private witness in the canonical intent digest. The recipient authorized by the signer — the note owner for transfers and deposits, or the withdrawal destination (constrained to equal publicRecipientAddress).
feeRecipientAddress: Private witness in the canonical intent digest. The optional designated recipient of the private fee note in output slot 2. If feeAmount > 0 and feeRecipientAddress == 0, the prover chooses output slot 2’s nonzero ownerAddress at proof generation time.
feeAmount: Private witness in the canonical intent digest. The optional private fee paid through output slot 2. 0 means no fee.
publicRecipientAddress: Public input. The withdrawal destination address; zero for deposits and 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("shielded_pool.<context_name>")) mod p
where p is the BN254 scalar field order (the field over which SNARK circuits and Poseidon operate) and <context_name> is the string identifier listed below. This derivation is deterministic and removes all domain tag TBDs.
The following domain tags are defined by this EIP (all use the shielded_pool. prefix):
Constant
Context string
Usage
NULLIFIER_DOMAIN
nullifier
Real note nullifiers
PHANTOM_DOMAIN
phantom
Phantom nullifiers
LABEL_DOMAIN
label
Deposit labels
INTENT_DOMAIN
intent
Intent nullifiers
NK_DOMAIN
nk
Nullifier key hashing
RANDOMNESS_DOMAIN
randomness
Deterministic output randomness
INTENT_DIGEST_DOMAIN
intent_digest
Canonical intent digest
AUTH_POLICY_DOMAIN
auth_policy
Auth policy registry leaves
AUTH_POLICY_KEY_DOMAIN
auth_policy_key
Auth policy registry tree keys
AUTH_VK_DOMAIN
auth_vk
Inner circuit VK hashing
OUTPUT_SECRET_DOMAIN
output_secret
Output secret hashing
USER_REGISTRY_LEAF_DOMAIN
user_registry_leaf
User registry leaves
All values are deterministically computable from the derivation formula above and MUST be < p.
3.2 Fixed Constants
MAX_INTENT_LIFETIME = 86400 (subject to change before Review) — maximum allowed validUntilSeconds offset from block.timestamp, in seconds (24 hours).
COMMITMENT_ROOT_HISTORY_SIZE = 500 — consensus-critical, fixed by spec.
USER_REGISTRY_ROOT_HISTORY_BLOCKS = 500 — consensus-critical, fixed by spec.
AUTH_POLICY_ROOT_HISTORY_BLOCKS = 64 — consensus-critical, fixed by spec. The contract accepts the current auth policy root or any root preserved from the last 64 blocks. See Section 5.2.
MIXED_LABEL — poseidon(LABEL_DOMAIN, 0xdead). Assigned to output notes when inputs have different labels (Section 12.2).
DUMMY_NK_HASH — poseidon(NK_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 shielded transfers.
WITHDRAWAL_OP = 1 — operation kind for withdrawals.
DEPOSIT_OP = 2 — operation kind for deposits.
3.3 Poseidon Hash Construction
This EIP uses Poseidon over the BN254 scalar field p (defined in Section 3.1) with the following parameters:
State width: t = 3 (2-arity, absorbing 2 field elements per permutation)
S-box: x^5 (α = 5)
Full rounds: R_F = 8
Partial rounds: R_P = 57
Round constants and MDS matrix: TBD. The intended instantiation follows the Grassi–Khovratovich–Rechberger–Roy parameter generation for BN254 at 128-bit security. Exact constants and test vectors MUST be provided before Review.
This EIP uses a single 2-input Poseidon primitive, hash_2(a, b), defined as one permutation on state [0, a, b] returning output element 0. All generic poseidon(x_0, ..., x_{n-1}) expressions are defined as an arity-prefixed wrapper over that primitive: poseidon(x_0, ..., x_{n-1}) = hash_2(n, tree(x_0, ..., x_{n-1})).
Here tree(...) is the left-balanced binary tree over the inputs, defined recursively: tree(x) = x; tree(a, b) = hash_2(a, b); for n > 2, the left subtree receives the largest power of 2 strictly less than n inputs and the right subtree receives the remainder. For example, poseidon(x) = hash_2(1, x), poseidon(a, b) = hash_2(2, hash_2(a, b)), and poseidon(a, b, c, d) = hash_2(4, hash_2(hash_2(a, b), hash_2(c, d))).
All poseidon(...) expressions in this EIP use this arity-prefixed construction. We write hash_n(...) as shorthand for poseidon(...) when emphasizing arity. Merkle tree internal nodes are the exception: they use raw hash_2(left, right) directly, not the arity-prefixed wrapper. A summary of hash contexts is in Section 13.
3.4 Merkle Tree Constructions
Unless otherwise stated, all Merkle trees in this EIP use hash_2(left, right) from Section 3.3.
Commitment tree. Depth-32 append-only binary Poseidon Merkle tree. 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_u32 (least-significant bit at height 0) determines whether the current hash is the left child (0) or the right child (1) when computing the parent as hash_2(left, right). For i in [0, 31], EMPTY_COMMITMENT[i + 1] = hash_2(EMPTY_COMMITMENT[i], EMPTY_COMMITMENT[i]) with EMPTY_COMMITMENT[0] = 0.
User registry tree. Depth-160 sparse binary Poseidon Merkle tree keyed by uint160(user). The key is a 160-bit big-endian bitstring; at depth d (d = 0 is MSB), bit 0 selects the left branch and bit 1 the right. Leaf value:
Empty leaf is 0. For i in [0, 159], EMPTY_USER[i + 1] = hash_2(EMPTY_USER[i], EMPTY_USER[i]) with EMPTY_USER[0] = 0.
Auth policy tree. Depth-160 sparse binary Poseidon Merkle tree. The auth-policy path is defined as the low 160 bits of poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash), interpreted big-endian. Path traversal follows the same convention as the user registry tree. Leaf value: poseidon(AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion). Empty leaf is 0. Same empty-node ladder convention.
4. Two-Circuit Architecture
This EIP uses a recursive proof architecture that splits the proof into two circuits with different trust properties.
Outer circuit (hard-fork-managed). There is exactly one outer circuit; it can only change via hard fork. It enforces all protocol invariants: value conservation, nullifier derivation, Merkle membership, deterministic output randomness, and registry lookups. It also recursively verifies an inner proof as part of its own verification. The outer circuit is the security boundary — a bug here can compromise the entire pool.
Inner circuit (permissionless). Anyone can write and deploy an inner circuit. It handles authentication — verifying the user’s credential — and intent parsing — computing a canonical digest of what the user authorized. It outputs four public values: [authorizingAddress, authDataCommitment, policyVersion, intentDigest]. The outer circuit checks these against its own state: credentials must match the auth policy registry, and the intent digest must match the outer circuit’s independent computation from execution data. Section 9.1 specifies the full per-mode constraints.
How they compose. A prover supplies the inner proof and inner verification key as private witnesses to the outer circuit. The outer circuit recursively verifies the inner proof, computes innerVkHash from the verification key, and uses it to look up the auth policy registry leaf. Because the inner verification key is a private witness, on-chain observers cannot determine which inner circuit (and therefore which auth method) was used. Section 9.1 specifies the full normative interface.
Responsibility
Circuit
Fork required?
Value conservation
Outer
Yes
Nullifier derivation
Outer
Yes
Merkle membership
Outer
Yes
Deterministic output randomness
Outer
Yes
Inner proof verification
Outer
Yes
Auth policy registry check
Outer
Yes
Intent nullifier derivation
Outer
Yes
Canonical intent digest computation
Outer
Yes
Signature verification
Inner
No
Intent parsing
Inner
No
Auth data commitment binding
Inner
No
policyVersion authentication
Inner
No
The outer circuit enforces protocol invariants that protect the entire pool. A weakened outer circuit could drain all funds. The inner circuit handles auth — a weakened inner circuit can only risk the registering user’s funds. This separation is what makes permissionless inner circuits safe.
Auth method anonymity. All auth methods share a single outer circuit. innerVkHash is never a public input — it is checked inside the circuit against the auth policy leaf. On-chain observers cannot determine which auth method was used for a given pool transaction. Auth policy registration is public (innerVkHash appears in the AuthPolicyRegistered event); the privacy property is transaction-time only.
Output note delivery.outputNoteData0, outputNoteData1, and outputNoteData2 are hash-bound to the proof via outputNoteDataHash0, outputNoteDataHash1, and outputNoteDataHash2 (public inputs), but their contents are not semantically constrained by the circuit. The inner circuit has no role in note delivery, and the outer circuit does not enforce any encryption scheme or delivery format. Section 15 defines the registry lookup plus the baseline scheme interpretation for ID 1; additional schemes MAY be defined later without changing the proof relation.
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 (elsewhere in this EIP) describe where computation runs. A self-hosted cloud server is first-party but remote.
Two proving configurations are supported:
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 nullifierKey, outputSecret, coin selection, witness construction, and note-delivery key lookup plus any supported delivery schemes.
Third-party proving. The user signs an intent 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. That discretion also extends to lineage policy: because the signed intent does not bind which notes are spent, a malicious prover can intentionally merge differently labeled notes and force MIXED_LABEL, degrading future provenance/selective-disclosure utility while still honoring the authorized payment semantics. It cannot forge unauthorized operations, redirect payments, or extract funds — these properties are enforced by the proof system regardless of prover behavior. However, because the protocol does not validate note-delivery payload contents, a malicious prover can choose unusable outputNoteData at proving time and render an in-flight transfer’s output notes unrecoverable.
On-chain
Third-party prover
Tx occurred
yes
yes
Token
deposits and withdrawals
yes
Amount
deposits and withdrawals
yes
Fee amount
no
yes
Fee recipient
no
yes
Sender
deposits
yes
Recipient
withdrawals
yes
Which notes spent
no
yes
Auth method used
no
yes
Shielded transfer public inputs reveal nothing beyond the fact that a transaction occurred. Opaque note-delivery payloads (outputNoteData0, outputNoteData1, outputNoteData2) are also on-chain; their size and structure may leak metadata depending on the delivery scheme and wallet payload conventions in use. Deposits expose depositor, token, and amount; the note recipient is private. Withdrawals expose amount, recipient, and token. feeAmount and the fee note’s recipient remain private in all modes; if feeRecipientAddress == 0 and feeAmount > 0, the prover chooses output slot 2’s owner at proof generation time. Auth method used is hidden at the proof level for all pool transactions; auth policy registration is public. For deposits, because depositorAddress is public, observers can narrow the auth method to that address’s registered auth-policy set. With first-party proving, the “Third-party prover” column does not apply.
Users MUST maintain independent backups of nullifierKey and either outputSecret or note plaintext including randomness. Loss of nullifierKey is permanent fund loss. Loss of outputSecret without note plaintext backups can make notes whose randomness has not otherwise been recovered unspendable. 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.
Third-party prover persistence. A third-party prover learns nullifierKey permanently and therefore retains the ability to monitor spends of previously known notes. It also learns the current outputSecret, so it can derive output randomness until that secret is rotated. After rotateOutputSecret and stale user roots expire, the old prover can no longer derive output randomness for future transactions by that address. Delivery keys are separate wallet-layer material; rotating or removing a delivery key does not affect note ownership or proof validity, but old delivery private keys 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 (TBD), following the pattern established by EIP-4788 (beacon block root), EIP-2935 (historical block hashes), EIP-7002 (execution layer exits), and EIP-7251 (consolidations).
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 (see Section 5.2).
5.2 State
The pool MUST maintain:
Commitment Merkle tree — append-only Poseidon Merkle tree (depth: 32, ~4B leaves). Empty leaf = 0. Holds multi-asset notes (tokenAddress is inside the commitment). The contract MUST revert if nextLeafIndex + 3 > 2^32 (since transact always inserts three commitments).
Commitment root history — circular buffer (size: COMMITMENT_ROOT_HISTORY_SIZE, consensus-critical). On each transact, the contract MUST push the pre-insertion 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 nullifier set — mapping(uint256 => bool).
User registry — depth-160 sparse Poseidon Merkle tree (Section 3.4), with block-based root history (window: USER_REGISTRY_ROOT_HISTORY_BLOCKS). History mechanics are defined in Section 5.2.1. The contract accepts the current root OR any historical root still within the window. Leaves commit to both nullifierKeyHash and outputSecretHash.
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 registry — depth-160 sparse Poseidon Merkle tree (Section 3.4), with block-based root history (window: AUTH_POLICY_ROOT_HISTORY_BLOCKS). History mechanics are defined in Section 5.2.1. The contract accepts the current root OR any historical root still within the window. Used by the outer circuit for inner circuit binding.
Policy versions — mapping(bytes32 => uint256) keyed by keccak256(abi.encodePacked(user, innerVkHash)), tracking the per-(address, innerVkHash) policyVersion counter. This mapping is the canonical source of truth for the next version to assign; the leaf value encodes the version at the time of its last write. Both are updated atomically in registerAuthPolicy.
5.2.1 Block-Based Registry Root Histories
The user registry and auth policy registry use block-based root histories. 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.
getCurrentRoots returns the current commitment root, current user-registry root, and current auth-policy root accepted by the contract.
getUserRegistryEntry returns the current user-registry entry for user, or (false, 0, 0) if the address is not registered.
getAuthPolicy returns whether the (user, innerVkHash) pair is currently active plus the current version counter for that pair. It MUST reject innerVkHash >= p (BN254 scalar field order) to avoid the same field-aliasing ambiguity as registerAuthPolicy/deregisterAuthPolicy. If the pair was never registered, it returns (false, 0). After deregistration, it returns (false, lastAssignedPolicyVersion).
These getters are the canonical online read path for wallets and provers. Clients MAY still reconstruct state from events, but the spec MUST NOT require replay from genesis as the only standard read path.
The two registerUser overloads are called by msg.sender to bind the caller’s address to a nullifier key hash and output-secret hash. The 4-argument overload also sets the caller’s initial registered delivery endpoint atomically with registration.
rotateOutputSecret is called by msg.sender to update only the outputSecretHash committed in the user registry. It is direct-only. The contract MUST revert if the caller is not registered. The new hash MUST be canonical (< p). The function updates the caller’s user-registry leaf in place and MUST maintain the block-based user-registry root history invariant (Section 5.2.1).
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 bind the (address, innerVkHash) pair to an auth data commitment. authDataCommitment is opaque. A single address may register multiple auth policies (one per innerVkHash); each has its own independent policyVersion.
MUST reject innerVkHash >= p or authDataCommitment >= p (BN254 scalar field order) to prevent field aliasing between the Poseidon tree key (which reduces mod p) and the keccak-based policyVersion mapping key (which does not).
Computes the auth-policy tree key as uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, msg.sender, innerVkHash)) (low 160 bits; see Section 3.4).
Computes the per-pair version key as keccak256(abi.encodePacked(msg.sender, innerVkHash)) and increments policyVersion for that pair (starting from 1 on first registration).
Computes the leaf poseidon(AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion).
MUST revert if the leaf equals 0 — the zero leaf is reserved for the absent/deregistered state (see deregisterAuthPolicy).
Writes the leaf at the composite key.
Root history update: On every auth-policy registration or deregistration, the contract MUST ensure the block-based root history invariant (Section 5.2.1) is maintained.
deregisterAuthPolicy is called by msg.sender to remove an auth policy. The contract MUST reject innerVkHash >= p (BN254 scalar field order) to prevent field aliasing at the auth-policy tree key. The contract writes 0 (the empty leaf) at the auth-policy tree key uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, msg.sender, innerVkHash)). Deregistration is direct-only. After stale auth-policy roots expire, no proof against that (address, innerVkHash) pair can succeed. MUST revert if the leaf is already 0. MUST emit:
After deregistration, the tree state is indistinguishable from “never registered” — history is carried by events, not the current leaf. Re-registration at the same (address, innerVkHash) pair continues from the existing policyVersion counter (which is not reset by deregistration), so old intents signed at pre-deregistration versions cannot match the re-registered leaf.
Addresses without a user-registry entry cannot receive or spend notes. The default (empty) leaf in the auth policy tree is 0, denoting absence. The outer circuit requires a membership proof at the auth-policy tree key uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash)) whose leaf matches poseidon(AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion) from the inner proof outputs; an unregistered pair has leaf 0 and no valid match exists.
5.4 Execution
On each call, the pool MUST execute the following steps:
transact MUST be non-reentrant.
Verify the proof via the verification precompile using proof and publicInputs.
Check merkle root. Require merkleRoot equals the current commitment root or is in the 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 registry root. Require authPolicyRegistryRoot equals the current auth policy root OR is in the auth policy registry block-based root history. authPolicyRegistryRoot MUST be nonzero.
Enforce nullifier uniqueness. Require nullifier0 != nullifier1 (defense-in-depth). The contract MUST NOT attempt to distinguish phantom nullifiers from real ones.
Mark nullifiers spent. Require both nullifiers are unspent; then mark them spent.
Mark intent nullifier used. Require intentNullifier is unused; then mark it used.
Insert commitments. Insert commitment0, commitment1, and commitment2 into the Merkle tree. Commitments MUST be nonzero — dummy outputs use nonzero dummy commitments (inserting 0 is indistinguishable from the tree’s empty leaf value).
Verify output note data hashes. Require uint256(keccak256(outputNoteData0)) % p == outputNoteDataHash0, uint256(keccak256(outputNoteData1)) % p == outputNoteDataHash1, and uint256(keccak256(outputNoteData2)) % p == outputNoteDataHash2. This binds the opaque payloads to the proof, preventing mempool observers or relayers from substituting payloads without invalidating the proof. The contract MUST NOT otherwise interpret or validate the payload contents.
Enforce public input ranges.
Require publicAmountIn < 2^248 and publicAmountOut < 2^248. Values in [2^248, p) pass field canonicality checks but could overflow the balance equation inside the circuit (Section 7.1).
Require publicRecipientAddress < 2^160, publicTokenAddress < 2^160, and depositorAddress < 2^160. Values in [2^160, p) are canonical field elements but alias when interpreted as EVM addresses.
Execute asset movement based on operation mode. Exactly one of the following three branches MUST match; the conditions are mutually exclusive:
Deposit (depositorAddress != 0):
Enforce deposit value constraints per Section 8.1 (msg.sender == depositorAddress, publicAmountIn > 0, publicAmountOut == 0, publicRecipientAddress == 0).
If publicTokenAddress == 0 (ETH): require msg.value == publicAmountIn.
If publicTokenAddress != 0 (ERC-20): require msg.value == 0. Record balBefore = balanceOf(address(this)). Execute transferFrom(msg.sender, address(this), publicAmountIn) and require success. Require balanceOf(address(this)) - balBefore == publicAmountIn, else revert.
Withdrawal (depositorAddress == 0 AND publicAmountOut > 0):
Require msg.value == 0.
Enforce withdrawal value constraints per Section 8.3 (publicAmountIn == 0, publicRecipientAddress != 0).
If publicTokenAddress == 0 (ETH): send publicAmountOut to publicRecipientAddress.
If publicTokenAddress != 0 (ERC-20): execute transfer(publicRecipientAddress, publicAmountOut) and require success.
The on-chain tx submitter MAY be a relayer whose address is irrelevant to the proof — only the intent tx signer matters.
Transfer (depositorAddress == 0 AND publicAmountOut == 0):
Require msg.value == 0.
Enforce transfer value constraints per Section 8.2 (publicAmountIn == 0, publicRecipientAddress == 0, publicTokenAddress == 0).
The on-chain tx submitter MAY be a relayer whose address is irrelevant to the proof — only the intent tx signer matters.
ERC-20 transfer, transferFrom, and balanceOf calls MUST use safe call semantics that handle non-standard return values (empty return data, boolean returns, reverts).
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 publicAmountOut on withdrawal. Such tokens MUST NOT be deposited.
leafIndex0 is the Merkle tree leaf index of commitment0; commitment1 is always at leafIndex0 + 1, and commitment2 is always at leafIndex0 + 2. postInsertionRoot is the commitment root after all three commitments have been inserted (distinct from publicInputs.merkleRoot, which is the pre-insertion root the proof was verified against). This makes tree reconstruction from events deterministic regardless of log ordering, and saves scanners from tracking insertion count from genesis.
Nullifiers and intentNullifier are indexed for efficient scanning and lookup. Commitments, postInsertionRoot, and all three outputNoteData* fields are non-indexed. Wallets discover incoming notes by scanning ShieldedPoolTransact events and interpreting the output note data per Section 15 and any additional supported delivery schemes.
Both registerUser overloads MUST emit UserRegistered. The 4-argument overload MUST also emit DeliveryKeySet. rotateOutputSecret MUST emit OutputSecretRotated. setDeliveryKey and removeDeliveryKey MUST emit the delivery-key events. Scanners use UserRegistered and OutputSecretRotated to maintain local copies of the user registry tree, and MAY cache current delivery endpoints from the delivery-key events. Wallets and provers MAY also use the direct read methods in Section 5.3 as the canonical online read path.
6. Registries
6.1 User Registry
The shielded pool MUST maintain a Poseidon Merkle tree mapping:
address → (nullifierKeyHash, outputSecretHash)
Root history follows the block-based model (Section 5.2.1, window: USER_REGISTRY_ROOT_HISTORY_BLOCKS).
Registration is REQUIRED before any pool operation that creates notes owned by an address. The circuit enforces that the depositor’s or recipient’s nullifierKeyHash matches a registry Merkle proof — an unregistered address cannot receive notes. This opt-in registration model lets the pool use ordinary Ethereum addresses as note owners, rather than requiring a separate privacy-native address format. Initial registration is a one-time operation per address via one of the registerUser overloads. Withdrawal recipients (publicRecipientAddress) do not need to be registered — withdrawals send to any Ethereum address. For the standardized on-chain address-only receive path, registered users SHOULD also set a delivery endpoint (Section 6.5).
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 (Section 5.3).
6.2 Registration Methods
The contract MUST provide:
registerUser(nullifierKeyHash, outputSecretHash) — callable by msg.sender. MUST revert if the address is already registered.
registerUser(nullifierKeyHash, outputSecretHash, schemeId, keyBytes) — callable by msg.sender. MUST revert if the address is already registered. This overload also initializes the caller’s registered delivery endpoint.
rotateOutputSecret(newOutputSecretHash) — callable by msg.sender. MUST revert if the address is not registered.
All registration methods MUST respect the block-based root history invariant (Section 5.2.1). Registration methods MUST reject nullifierKeyHash >= p or outputSecretHash >= p to prevent field aliasing between on-chain storage and in-circuit Poseidon computation. rotateOutputSecret MUST reject newOutputSecretHash >= p. All registration methods MUST compute the resulting user-registry leaf and revert if it equals 0 — the zero leaf is reserved for the absent state.
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. Users who do not want to use this registry use the 2-argument overload and MAY call setDeliveryKey later.
6.3 Key Mutability
nullifierKeyHash is immutable — rotating it would make existing notes unspendable. A compromised nullifier key requires migration to a new address.
outputSecretHash is rotatable via rotateOutputSecret. Rotating it does not affect ownership of existing notes, but changes the deterministic randomness used for future outputs after stale user roots expire. After rotation, users MUST retain the prior outputSecret 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 binds (address, auth-method) pairs to credentials. State layout is specified in Section 5.2. Registration is via registerAuthPolicy (direct only). See Section 5.3.
Wallets and provers can read the current status/version for a specific (address, innerVkHash) pair via getAuthPolicy and the current accepted roots via getCurrentRoots (Section 5.3).
Rotation and revocation. Auth policy rotation is bounded-delay, not instant, and operates per auth method. Other auth methods registered by the same address are unaffected. The old auth-policy root remains valid for up to AUTH_POLICY_ROOT_HISTORY_BLOCKS blocks. During this window, old intents (signed with the old policyVersion) remain provable against the stale root. If the user’s own leaf changed (rotation or deregistration), the intent becomes permanently unprovable once the stale root expires.
Full revocation of a specific auth method becomes effective once the stale auth root ages out of the bounded history window. After that point:
The old root is no longer accepted by the contract (Section 5.4, step 6).
Old intents carry the old policyVersion → inner circuit outputs the old version → mismatch with the current registry leaf → outer proof failure.
Re-registering identical credentials at a higher policyVersion does NOT resurrect old intents. The inner circuit outputs the policyVersion from the old signed artifact, which mismatches the new leaf’s incremented version.
Rotating one auth method does not affect intents signed with other auth methods for the same address — each (address, innerVkHash) pair has its own policyVersion.
Adding a new auth method. To add a new auth method:
Publish an inner circuit that verifies the new signature scheme and outputs [authorizingAddress, authDataCommitment, policyVersion, intentDigest].
Users register their credentials via registerAuthPolicy with the inner circuit’s innerVkHash and their authDataCommitment. Existing auth policies for other inner circuits remain active — the new registration creates a new leaf at a distinct composite key.
Done — no hard fork required.
Constraints: the inner circuit MUST conform to the inner-proof envelope (Section 9.1). The innerVkHash encoding follows the Barretenberg canonical serialization order (Section 9.1, step 2; to be pinned before Review). Companion ERCs MUST authenticate all canonical digest fields including policyVersion. Companion ERCs MUST domain-separate authorizations such that an authorization valid for one innerVkHash is invalid for any other innerVkHash. Auth methods requiring a different proof system need a hard fork that updates the outer circuit.
Cross-circuit note compatibility. Note commitments bind to (ownerAddress, nullifierKeyHash) — neither field encodes an auth method. A note created with any inner circuit is spendable with any other inner circuit, provided the user has registered an auth policy for the spending circuit’s innerVkHash.
All inner circuits share the same note tree, nullifier set, and anonymity set — adding a new auth method requires only a new registerAuthPolicy call (creating a leaf at a new composite key), not a fund transfer. Both old and new auth methods remain usable simultaneously.
Deactivation. A user can deregister an auth method via deregisterAuthPolicy (Section 5.3), which writes the empty leaf (0) at the composite key. Deactivation is bounded-delay: the old auth-policy root remains valid for up to AUTH_POLICY_ROOT_HISTORY_BLOCKS blocks after deregistration. After expiry, no proof against that (address, innerVkHash) pair can succeed. A user may also replace credentials by re-registering with a new authDataCommitment, which increments policyVersion and invalidates old authorizations after stale roots expire. Global disabling of auth methods (e.g., pre-quantum schemes) requires a hard fork.
6.5 Delivery Key Registry
The same system contract also 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 a recipient. In first-party proving this is the wallet or user software; in delegated proving it is typically the prover. Delivery may also be coordinated out of band.
If getDeliveryKey(recipient) == (0, ""), this EIP provides no on-chain delivery metadata for that recipient. The transaction can still be constructed because outputNoteData is protocol-opaque, but note delivery must be coordinated out of band. Wallets SHOULD treat registry-based address-only private send as unavailable in that case.
Delivery-key registration is direct-only. Initial delivery-key setup MAY be combined with direct user registration via the 4-argument registerUser overload.
7. Note Commitment and Nullifiers
7.1 Address and Amount Constraints
Inside the circuit:
All address-valued fields (ownerAddress, authorizingAddress, tokenAddress, depositorAddress, publicRecipientAddress, recipientAddress, feeRecipientAddress) 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. The contract MUST also reject public publicRecipientAddress, publicTokenAddress, or depositorAddress values >= 2^160 before interpreting them as EVM addresses.
Amounts MUST be constrained to < 2^248. ERC-20 amounts are uint256, but the SNARK field is ~254 bits. The balance equation sums at most 4 terms per side; 4 * 2^248 < p prevents field overflow. The contract MUST also reject publicAmountIn or publicAmountOut values >= 2^248.
7.2 Note Commitment
Notes MUST commit to exactly the following fields. This EIP defines exactly one note type and one corresponding verification path. A future hard fork MAY define additional note types or migration paths, but such extensions are out of scope for this specification.
ownerAddress — 20-byte Ethereum address. The note owner: set to recipientAddress for transfer recipient notes and deposit notes, authorizingAddress for sender change notes (transfers and withdrawals), or the fee-note recipient (feeRecipientAddress when nonzero, otherwise prover-selected) for fee notes.
randomness — blinding factor. For newly created notes it is deterministically derived from the sender’s outputSecret and intentNullifier (Section 9.5).
nullifierKeyHash — hash of the owner’s nullifier key: poseidon(NK_DOMAIN, nullifierKey).
tokenAddress — ERC-20 contract address, or 0 for ETH.
label — cryptographic lineage tag (see Section 12).
The binary-tree Poseidon construction and exact input ordering are defined in Section 3.3.
nullifierKey — a secret scalar known only to the note owner. Required to spend notes. Loss of this key means permanent loss of access to the associated shielded funds. Key derivation and storage are implementation-defined.
leafIndex_u32 — position in the Merkle tree, as u32 (not raw Field) to prevent index aliasing double-spends.
randomness — the note’s blinding factor.
7.4 Phantom Nullifier
If an input slot is phantom, the circuit MUST use:
outputSecret is used only for deterministic output randomness. It does not affect note ownership, nullifier derivation, or wallet-layer note delivery. Unlike nullifierKey, it is rotatable through the user registry (Section 6.3).
8. Operation Modes
The pool supports three operation modes, determined by public inputs:
8.1 Deposit Mode
Deposit mode is selected when depositorAddress != 0.
Requirements:
The depositor MUST be registered in the user registry (Section 6.1).
The depositor MUST have a registered auth policy (Section 6.4).
The recipient MUST be registered in the user registry — the circuit requires the recipient’s nullifierKeyHash for output note commitment binding.
If feeAmount != 0, output slot 2’s owner MUST be registered in the user registry.
Inner proof REQUIRED (Section 9.1).
msg.sender == depositorAddress.
publicTokenAddress specifies the deposited asset (0 for ETH, otherwise an ERC-20 address).
publicAmountIn > 0.
publicAmountOut == 0.
publicRecipientAddress == 0.
Both input slots MUST be phantom.
publicAmountIn == amount + feeAmount, where amount is the canonical-intent amount and feeAmount is the optional private fee.
Output slot 0 MUST be one real output owned by recipientAddress (from the canonical intent digest), with amount equal to the canonical-intent amount and tokenAddress == publicTokenAddress.
Output slot 1 MUST be dummy.
Output slot 2 MUST be a fee note with amount == feeAmount. If feeRecipientAddress != 0, its ownerAddress MUST equal feeRecipientAddress. If feeAmount > 0 and feeRecipientAddress == 0, its ownerAddress MUST be prover-selected and nonzero. If feeAmount == 0, output slot 2 MUST be dummy.
Standard intent expiry: validUntilSeconds > 0 (Section 5.4, step 3).
Standard intent nullifier derived from (nullifierKey, intentDigest) (Section 9.8).
operationKind = DEPOSIT_OP (derived from depositorAddress != 0; no new operation kind).
Deposits expose token, amount, and depositor address on-chain; the note recipient is private.
8.2 Transfer Mode (Shielded Transfer)
Transfer mode is selected when:
depositorAddress == 0
publicAmountIn == 0
publicAmountOut == 0
publicRecipientAddress == 0
publicTokenAddress == 0
In transfer mode the token MUST be private (enforced inside the circuit); the on-chain transaction MUST NOT reveal token or amount. The transfer anonymity set spans all tokens because publicTokenAddress is zero.
Coin selection is delegated to the prover. The intent binds payment semantics (recipient, amount, token, operation type), not which notes are spent or which labels merge. Operation-type binding is the inner circuit’s responsibility via operationKind in the canonical intent digest.
Output slot 0 is the recipient payment note, output slot 1 is sender change or dummy, and output slot 2 is the fee note or dummy.
8.3 Withdrawal Mode (Public Withdrawal)
Withdrawal mode is selected when:
depositorAddress == 0
publicAmountIn == 0
publicAmountOut > 0
publicRecipientAddress != 0
publicTokenAddress specifies the withdrawn token (0 for ETH, otherwise ERC-20 address)
Withdrawals are public with respect to token, amount, and recipient address.
Output slot 0 is sender change or dummy, output slot 1 MUST be dummy, and output slot 2 is the fee note or dummy.
9. Circuit Requirements
This EIP specifies a recursive proof architecture. The outer circuit (hard-fork-managed) enforces protocol invariants. Inner circuits (permissionless) handle authentication and intent parsing. The outer circuit recursively verifies an inner circuit proof as part of its own verification.
Invariants (permanent, enforced by the outer circuit):
Note commitment format (Section 7.2)
Nullifier derivation from nullifierKey — this is why cross-circuit spending works
Value conservation constraints
Commitment tree structure and nullifier set
User registry (Section 6): the outer circuit proves nullifierKeyHash and the sender’s outputSecretHash against it
Deterministic output randomness
Independent extension axes:
Auth method: permissionless via inner circuits (Section 4, Section 6.4)
Intent format: inner-circuit-determined, specified by companion standards
Note-delivery scheme: the baseline interoperable scheme is defined in Section 15; payload hashes are proof-bound but contents are not semantically constrained, and additional schemes MAY be defined later
9.1 Authorization — Inner/Outer Split
The outer circuit MUST use depositorAddress (a public input) to determine the operation mode. The public-input constraints for each mode (amount directions, phantom/dummy slot requirements) are defined in Section 8. This section specifies the additional circuit-level enforcement per mode.
Inner VK Hash:innerVkHash is a Poseidon hash of the inner circuit’s verification key, which uniquely identifies the inner circuit. The outer circuit computes it from the VK provided as a private witness and uses it to look up the auth policy registry. Exact VK serialization format and maximum size MUST be pinned before Review.
Deposit mode (depositorAddress != 0):
The outer circuit performs inner proof verification where authorizingAddress is the depositor and recipientAddress is the output note owner:
Computes innerVkHash from innerVkey (Inner VK Hash). Proves auth policy membership at key uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash)).
Computes canonical intent digest from private witnesses and public inputs (Section 9.11) — recipientAddress is one of the witnesses used in this computation. Enforces the result matches intentDigest from the inner proof output. This binds recipientAddress to the signed intent.
Enforces authorizingAddress == depositorAddress — the signer must be the depositor.
Binds authorizingAddress to the depositor’s user registry entry (nullifierKeyHash, outputSecretHash).
Derives intentNullifier from (nullifierKey, intentDigest) (Section 9.8).
Proves the recipient’s user registry entry using recipientAddress — obtains the recipient’s nullifierKeyHash for output note commitment binding.
Constrains output slot 0’s ownerAddress to recipientAddress.
Constrains output slot 2 to either a fee note (amount == feeAmount, owner determined per Section 9.5) or dummy.
Enforces publicAmountIn == amount + feeAmount.
Output slot 1 MUST be dummy.
The circuit must prove two or three user registry entries: depositor + recipient, and additionally output slot 2’s owner if feeAmount != 0. Inner circuits for deposits MUST parse and bind authorizingAddress = depositorAddress, recipientAddress = output slot 0 owner, amount = output slot 0 amount, feeRecipientAddress, feeAmount, and tokenAddress = publicTokenAddress from the signed artifact.
Transfer/withdrawal mode (depositorAddress == 0):
The outer circuit:
Recursively verifies the inner proof against innerVkey with public inputs [authorizingAddress, authDataCommitment, policyVersion, intentDigest].
Computes innerVkHash from innerVkey (Inner VK Hash).
Computes auth-policy tree key uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash)) and proves auth policy membership at that key. The outer circuit computes poseidon(AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion) using the inner proof outputs and verifies this equals the leaf opened at the composite key. innerVkHash is bound via the composite key, not stored in the leaf.
Computes canonical intent digest from execution data (Section 9.11), enforces intentDigest from inner outputs matches.
Binds authorizingAddress to note ownership via user registry (nullifierKeyHash and outputSecretHash).
Derives intentNullifier from (nullifierKey, intentDigest) (Section 9.8).
Inner Circuit Interface (normative):
Inner circuit public output vector — 4 field elements, fixed order:
authorizingAddress — signer’s address, identified by the inner circuit’s auth process. MUST come from the inner circuit.
policyVersion — authenticated from the signed artifact. Outer circuit checks it matches registry leaf. If the registered version has changed since signing, the mismatch causes proof failure.
intentDigest — canonical digest computed from parsed intent fields. Outer circuit checks it matches its own computation.
innerVkHash is NOT an inner circuit output — the outer circuit computes it from the verification key used for recursive verification.
Inner-proof envelope (normative): Inner circuits MUST conform to the outer circuit’s proof system and curve (UltraHONK/BN254; to be pinned before Review). Exact proof serialization format and canonical vkey encoding for innerVkHash MUST be pinned before Review. Auth methods requiring a different proof system need a hard fork.
Security property: The inner circuit MUST NOT have access to nullifierKey or outputSecret. Specifically, neither secret MUST appear as a witness or public input in the inner proof relation. The outer circuit derives intentNullifier and output randomness independently.
Normative equality constraints (MUST):
authorizingAddress from inner proof == address used for auth policy lookup, user registry lookup, nullifier derivation, and change note ownership. recipientAddress from the canonical intent digest determines recipient note ownership (transfers) and deposit note ownership. If feeRecipientAddress != 0, it determines fee-note ownership in output slot 2; otherwise output slot 2 ownership is prover-selected at proof generation time. For deposits, authorizingAddress is additionally constrained to equal depositorAddress.
innerVkHash computed from innerVkey == innerVkHash used in auth-policy tree key uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash))
authDataCommitment from inner proof == authDataCommitment in auth policy leaf
policyVersion from inner proof == policyVersion in auth policy leaf
intentDigest from inner proof == outer circuit’s computed digest
9.2 Note Ownership and Membership
For each input slot:
If isPhantom == 0 (real input): the circuit MUST prove Merkle membership in merkleRoot. The commitment MUST include the signer’s address, so only notes owned by the signer match.
If isPhantom == 1 (phantom input): membership MUST be skipped. The circuit MUST enforce nullifier = poseidon(PHANTOM_DOMAIN, nullifierKey, intentNullifier, slotIndex) and amount = 0.
isPhantom MUST be constrained to 0 or 1.
In transfer and withdrawal modes (depositorAddress == 0), at least one input MUST be real (isPhantom == 0). (For withdrawals this is already implied by value conservation and publicAmountOut > 0; the constraint is stated explicitly for defense-in-depth.)
This binds the nullifier key to the key hash committed in the note.
For phantom input slots, the nullifier-key binding MUST be skipped.
In deposit mode (both inputs phantom), the circuit MUST still enforce that poseidon(NK_DOMAIN, nullifierKey) == registryNullifierKeyHash(authorizingAddress), where authorizingAddress is from the inner proof (constrained to equal depositorAddress per Section 9.1) and registryNullifierKeyHash is the depositor’s registered nullifier key hash proven via the user registry Merkle proof. This prevents an untrusted prover from choosing an arbitrary nullifierKey for deposit outputs.
where registryOutputSecretHash(authorizingAddress) is extracted from the sender’s user-registry leaf. This binds deterministic output randomness to a rotatable sender-side secret.
Both sides MUST include range checks to prevent overflow. publicAmountIn and publicAmountOut are public inputs bound by this constraint.
9.5 Output Well-Formedness and Determinism
For each output slot, per-slot isDummy flag (constrained to 0 or 1):
If isDummy == 0 (real output): Real output notes MUST have amount > 0. The output commitment MUST be correctly formed for its owner and token. nullifierKeyHash MUST match the registry-proven key hash for that output’s owner: recipient note, sender change note, or fee note. Additional per-mode constraints:
Transfer: Per Section 8.2. The circuit MUST enforce that output slot 0 is the recipient payment (note ownerAddress = recipientAddress, nullifierKeyHash = recipient’s registry-proven key hash, amount = authorized amount, tokenAddress = authorized token), output slot 1 is sender change or dummy (note ownerAddress = authorizingAddress, nullifierKeyHash = sender’s registry-proven key hash), and output slot 2 is a fee note or dummy (note ownerAddress = feeRecipientAddress if nonzero, otherwise prover-selected and nonzero; nullifierKeyHash = that owner’s registry-proven key hash; amount = feeAmount).
Withdrawal: Per Section 8.3. Output slot 0 is sender change or dummy, output slot 1 MUST be dummy, and output slot 2 is a fee note or dummy.
Deposit: Per Section 8.1. Output slot 0 is the recipient note, output slot 1 MUST be dummy, and output slot 2 is a fee note or dummy.
If isDummy == 1 (dummy output):
amount MUST equal 0.
ownerAddress MUST equal 0.
tokenAddress MUST equal 0.
label MUST equal 0.
nullifierKeyHash MUST equal DUMMY_NK_HASH.
The amount == 0 constraint prevents value extraction even if a preimage for DUMMY_NK_HASH were found.
For output slot 2 specifically, the circuit MUST enforce:
feeAmount == 0 iff output slot 2 is dummy, and then feeRecipientAddress == 0.
feeAmount > 0 iff output slot 2 is real.
If feeAmount > 0 and feeRecipientAddress != 0, then ownerAddress == feeRecipientAddress.
If feeAmount > 0 and feeRecipientAddress == 0, then ownerAddress MUST be nonzero. In that case the prover chooses output slot 2’s owner at proof generation time.
Output note randomness MUST be deterministically derived for both real and dummy output slots:
Dummy outputs use the same randomness derivation as real outputs. This removes prover discretion over dummy commitments. The resulting commitment remains subject to the existing nonzero-commitment rule (Section 5.4, step 10).
For a fixed witness assignment (same input notes, same slot ordering, same accepted registryRoot, same outputSecret), output randomness is deterministic. This removes prover discretion over commitments given a fixed witness, but coin selection, slot assignment, and registry root selection (within the valid history window) are not canonicalized.
9.6 Registry Binding
Gated by operation type:
Transfer: the outer circuit MUST prove the recipient address has a user registry entry so output slot 0 can bind the recipient’s nullifierKeyHash. The outer circuit MUST also prove the sender (authorizingAddress from inner proof) has a user registry entry, extracting both nullifierKeyHash and outputSecretHash. If feeAmount != 0, the outer circuit MUST additionally prove output slot 2’s ownerAddress has a user registry entry. The outer circuit MUST prove auth policy membership for authorizingAddress (see Section 9.1).
Withdrawal: the outer circuit MUST prove the sender has a user registry entry, extracting both nullifierKeyHash and outputSecretHash, so any change note binds the sender’s note key and output randomness derives from the registered output secret. If feeAmount != 0, the outer circuit MUST additionally prove output slot 2’s ownerAddress has a user registry entry. The outer circuit MUST prove auth policy membership for authorizingAddress. Recipient binding is skipped — the recipient receives unshielded funds via publicRecipientAddress. Any address can be a withdrawal destination; compliance is handled by counterparty-level selective-disclosure protocols, not by registry membership.
Deposit: the outer circuit MUST prove the depositor (authorizingAddress) has a user registry entry, extracting both the depositor’s nullifierKeyHash and outputSecretHash. The circuit MUST additionally prove the recipient (recipientAddress from the canonical intent digest) has a user registry entry, extracting the recipient’s nullifierKeyHash for output slot 0 commitment binding. If feeAmount != 0, the circuit MUST additionally prove output slot 2’s ownerAddress has a user registry entry. The outer circuit MUST prove auth policy membership for authorizingAddress.
9.7 Output Note Data
outputNoteDataHash0, outputNoteDataHash1, and outputNoteDataHash2 are public inputs that bind opaque note-delivery payloads to the proof. The outer circuit treats these hashes as unconstrained pass-throughs — they are not checked against any encryption scheme or delivery format. The prover computes outputNoteDataHash0 = uint256(keccak256(outputNoteData0)) % p and includes it as a public input; the contract independently computes the same value from calldata and verifies equality. Likewise for outputs 1 and 2. This prevents third parties from substituting payloads without invalidating the proof. All 19 public inputs — including these unconstrained hash fields — are part of the verification equation; the verifier does not distinguish constrained from unconstrained public inputs.
The outer and inner circuits do not constrain the contents of the payloads. Section 15 defines the baseline registry lookup and the interpretation for scheme ID 1; additional schemes MAY define other payload formats without changing proof semantics.
nullifierKey — the owner’s secret; makes the nullifier unguessable.
intentDigest — the canonical authorization hash (Section 9.11). Because the digest encodes the full semantic authorization (including policyVersion, authorizingAddress, rawNonce, operationKind, executionChainId), it uniquely identifies the authorized action. Replay protection is keyed to the canonical intent digest, which includes policyVersion. Since policyVersion is per (address, innerVkHash), two auth methods with different versions produce different digests and independent intent nullifiers. When two auth methods share the same policyVersion (e.g., both at version 1 after initial registration), the same semantic intent produces the same digest and intent nullifier — executing via one method prevents execution via the other.
rawNonce binding:rawNonce is a private witness of the outer circuit. The outer circuit uses it in the canonical digest computation (Section 9.11). The intent nullifier consumes the digest (not rawNonce directly), so rawNonce is bound to the nullifier through the digest. Digest equality against the inner proof’s intentDigest output ensures the outer circuit’s rawNonce matches the value the inner circuit parsed from the signed artifact.
Range constraints: The outer circuit MUST constrain rawNonce < 2^64 and validUntilSeconds < 2^32. Without these range checks, field aliasing could allow two distinct nonce or timestamp values to produce the same intent nullifier or canonical digest (e.g., x and x + p reduce to the same field element but are different uint256 values). UNIX seconds fit 32 bits until 2106; 64 bits provides ample nonce space.
Deposit mode:
Deposits use the same derivation as transfers/withdrawals:
The deposit-specific fields (depositorAddress, recipientAddress, amount, feeRecipientAddress, feeAmount, publicAmountIn, publicTokenAddress) are bound through the canonical intent digest and value-conservation rules (Section 9.11).
9.9 Label Propagation
The circuit MUST enforce output labels per Section 12.
9.10 Token Consistency
All real input and output notes MUST use the same tokenAddress.
For deposits and withdrawals: tokenAddress == publicTokenAddress. This binds the notes’ private token to the public input that drives fund movement.
For transfers: publicTokenAddress == 0. Token consistency is enforced privately within the circuit.
9.11 Canonical Intent Digest
The canonical intent digest is the single semantic authorization hash binding the user’s intent to the proof. Both inner and outer circuits compute it independently; the outer circuit enforces equality.
poolAddress (SHIELDED_POOL_ADDRESS) makes the authorization target explicit — cheap defense-in-depth against cross-contract replay if another contract ever uses a similar digest scheme. In the outer circuit, poolAddress is a constant hardcoded at compilation time (fork-managed, like the outer verification key). In the inner circuit, it comes from the signed artifact (e.g., the EIP-712 domain’s verifyingContract).
intentNullifier is NOT in the digest. intentNullifier depends on nullifierKey, which the inner circuit MUST NOT access. The outer circuit derives intentNullifier from (nullifierKey, intentDigest) — see Section 9.8.
feeRecipientAddress MAY be zero. If feeAmount > 0 and feeRecipientAddress == 0, output slot 2’s ownerAddress is chosen by the prover at proof generation time. That address is not part of the canonical intent digest and is fixed only by the resulting proof.
The outer circuit computes the digest from private witness values and public inputs. The inner circuit computes it from parsed signed intent fields. Must match. The outer circuit MUST derive operationKind from the public execution mode — it MUST NOT treat operationKind as an unconstrained witness. Derivation: depositorAddress != 0 → DEPOSIT_OP; depositorAddress == 0 AND publicAmountOut > 0 → WITHDRAWAL_OP; depositorAddress == 0 AND publicAmountOut == 0 → TRANSFER_OP.
Normative execution-field binding (MUST):
Withdrawal: recipientAddress == publicRecipientAddress, amount == publicAmountOut, tokenAddress == publicTokenAddress, validUntilSeconds == public input, executionChainId == block.chainid (checked by contract). feeRecipientAddress and feeAmount are private. If feeAmount > 0 and feeRecipientAddress == 0, output slot 2’s ownerAddress is prover-selected and privately bound by the proof.
Transfer: recipientAddress, amount, feeRecipientAddress, and feeAmount are private (bound through digest equality, output constraints, and value conservation), tokenAddress is private (bound through token consistency, Section 9.10), validUntilSeconds == public input, executionChainId == block.chainid (checked by contract), publicRecipientAddress == 0, publicAmountOut == 0, publicAmountIn == 0, publicTokenAddress == 0. If feeAmount > 0 and feeRecipientAddress == 0, output slot 2’s ownerAddress is prover-selected and privately bound by the proof.
Deposit: authorizingAddress == depositorAddress, recipientAddress = output slot 0 owner (from signed intent), amount = output slot 0 amount, tokenAddress == publicTokenAddress, publicAmountIn == amount + feeAmount, validUntilSeconds == public input, executionChainId == block.chainid (checked by contract). If feeAmount > 0 and feeRecipientAddress == 0, output slot 2’s ownerAddress is prover-selected and privately bound by the proof.
10. Public Inputs
The outer verifier’s public-input vector is the 19 fields of PublicInputs (Section 5.3), in declaration order.
merkleRoot — commitment tree root the proof is verified against.
nullifier0, nullifier1 — input note nullifiers. nullifier1 is phantom when unused.
commitment0, commitment1, commitment2 — output note commitments. commitment0 is the primary user-facing output note, commitment1 is sender change or dummy, and commitment2 is a fee note or dummy.
publicAmountIn — tokens entering the shielded state (deposits); 0 otherwise.
publicAmountOut — tokens leaving the shielded state (withdrawals); 0 otherwise.
publicRecipientAddress — withdrawal destination address; 0 for deposits and transfers.
publicTokenAddress — token being transacted (0 for ETH); 0 for transfers.
depositorAddress — depositor’s Ethereum address (deposits); 0 for transfers/withdrawals.
intentNullifier — replay protection.
registryRoot — user registry root. MUST be nonzero.
validUntilSeconds — intent expiry timestamp. MUST be > 0 for all operation modes.
executionChainId — verified by the contract against block.chainid (Section 5.4, step 2). Defense-in-depth against cross-chain proof replay.
authPolicyRegistryRoot — auth policy registry root. MUST be nonzero for all operation modes.
outputNoteDataHash0 — uint256(keccak256(outputNoteData0)) % p. Binds the first output’s opaque note-delivery payload to the proof.
outputNoteDataHash1 — uint256(keccak256(outputNoteData1)) % p. Binds the second output’s opaque note-delivery payload to the proof.
outputNoteDataHash2 — uint256(keccak256(outputNoteData2)) % p. Binds the third output’s opaque note-delivery payload to the proof.
publicAmountIn and publicAmountOut apply to the token specified by publicTokenAddress. For transfers, all three are zero.
authorizingAddress, policyVersion, innerVkHash, and authDataCommitment are NOT public inputs — they are private, known only inside the circuit.
10.1 Canonical Field Element Validation
The verifier MUST reject any public input that is not a canonical field element (i.e., >= p, the SNARK field modulus). Without this, x and x + p would verify identically but map to different uint256 keys in contract storage, enabling nullifier reuse or intent replay.
11. Precompile
11.1 Proof Verification
The precompile verifies proofs for the outer circuit (UltraHONK/BN254; to be pinned before Review). The verification key is fork-defined. Exact proof serialization and verification key formats MUST be pinned before Review.
Address: PROOF_VERIFY_PRECOMPILE_ADDRESS (TBD)
Input: abi.encode(bytes proof, PublicInputs publicInputs) — the struct fields are ABI-encoded as 19 consecutive uint256 values in declaration order.
Output: 32 bytes — uint256(1) on success, empty on failure.
Gas: TBD (MUST be set before advancing to Review).
Error: malformed input or verification failure returns empty.
12. Labels and Lineage
Every note MUST carry a label field. For single-origin notes (never merged with notes from a different deposit), the label is a Poseidon hash that traces the note’s lineage back to its original deposit. For mixed-origin notes, the label is MIXED_LABEL — a sentinel indicating that provenance was lost at a merge point. Labels are enforced by the circuit; they cannot be forged.
12.1 Deposit Label
In deposit mode, output labels MUST be derived from the deposit’s public inputs:
publicAmountIn is the total public deposit amount, not individual output note amounts.
executionChainId (= block.chainid) prevents cross-chain label collisions. intentNullifier is unique per transaction and known at proof generation time.
12.2 Transfer Label Propagation
One real input (one phantom): output labels MUST inherit the real input’s label. Phantom slots contribute no label.
Two real inputs, same label: output labels MUST inherit that label.
Two real inputs, different labels: all output labels MUST equal MIXED_LABEL (Section 3.2). Provenance is lost at the protocol level — the label no longer traces back to a specific deposit.
Both inputs phantom (deposit mode): output labels are the freshly derived deposit label (Section 12.1).
The Merkle tree node row uses hash_2(left, right) directly — not the arity-prefixed poseidon(...) construction (Section 3.3). All other rows use the arity-prefixed form. The Inner VK hash row’s exact serialization, padding, and arity MUST be pinned before Review (see Inner VK Hash in Section 9.1).
14. Example ECDSA Inner Circuit (Non-Normative)
This section sketches an example inner circuit for ECDSA/secp256k1 authorization using EIP-712 typed data signing.
The user signs an EIP-712 typed struct containing all intent fields:
EIP-712 domain: { name: "ShieldedPool", version: "1", chainId: <executionChainId>, verifyingContract: <poolAddress> }. The domain binds executionChainId and poolAddress without repeating them in the struct. innerVkHash in the signed struct satisfies the cross-innerVkHash domain separation requirement (Section 6.4): a signature valid for one inner circuit is invalid for any other. innerVkHash is not part of the canonical intent digest (Section 9.11) — it is included here only for cross-circuit domain separation.
The inner circuit:
Computes the EIP-712 signing hash from the struct and domain.
Verifies the ECDSA signature against the provided secp256k1 public key (ecdsaPubKeyX, ecdsaPubKeyY). Derives authorizingAddress from the public key via keccak256(pubKeyX || pubKeyY)[12:].
Reads all intent fields directly from the struct (no packing or extraction needed).
Computes the canonical intent digest per Section 9.11.
Outputs [authorizingAddress, authDataCommitment, policyVersion, intentDigest] where authDataCommitment = poseidon(ecdsaPubKeyX, ecdsaPubKeyY).
15. Output Note Data and Delivery Keys
outputNoteData0, outputNoteData1, and outputNoteData2 are opaque bytes emitted alongside the three output commitments in ShieldedPoolTransact. The contract verifies only the hash binding (Section 9.7) and MUST NOT decode or validate payload contents.
When using the delivery-key registry, the party constructing outputNoteData for a recipient looks up the recipient’s active registered delivery endpoint via getDeliveryKey(recipient). In first-party proving this is the wallet or user software; in delegated proving it is typically the prover. The sender/prover then constructs payload bytes according to the selected delivery scheme. Notes MAY also be delivered using out-of-band coordination.
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.
15.1 Scheme IDs and Support Requirements
The delivery-key registry uses the following scheme-ID namespace:
0 — unset / invalid
1 — X-Wing (X25519 + ML-KEM-768 hybrid KEM)
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.
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.
15.2 Scheme 1: X-Wing (X25519 + ML-KEM-768)
For scheme 1, keyBytes MUST be a raw 1216-byte X-Wing encapsulation key: the 1184-byte ML-KEM-768 encapsulation key followed by the 32-byte X25519 public key. This EIP pins the X-Wing KEM to draft-connolly-cfrg-xwing-kem-10, including key generation, encapsulation, decapsulation, and combiner behavior.
The plaintext is the six note fields in note-commitment order, each encoded as a 32-byte big-endian word:
Address fields are the corresponding uint160 values left-padded to 32 bytes.
outputNoteData for scheme 1 MUST be exactly 1328 bytes and encoded as enc || ciphertext || tag, where:
enc is the first 1120 bytes and is the raw X-Wing ciphertext: the 1088-byte ML-KEM-768 encapsulation ciphertext followed by the 32-byte X25519 ephemeral public key
ciphertext is the next 192 bytes and is the AES-256-GCM ciphertext of the 192-byte plaintext above
tag is the final 16 bytes and is the AES-256-GCM authentication tag
The sender/prover encapsulates to the registered X-Wing public key, obtaining enc plus the 32-byte X-Wing shared secret, and then derives the AEAD key and nonce from that shared secret with HKDF-SHA256:
The recipient decapsulates enc with the corresponding X-Wing private key, derives the same AEAD key and nonce, verifies tag, decrypts ciphertext, recomputes the note commitment, and MUST reject on mismatch.
15.3 Additional Schemes
Additional nonzero scheme IDs MAY be assigned by later standards without changing proof semantics or the ShieldedPool contract interface. Such standards define the meaning of keyBytes and any payload interpretation for their assigned IDs.
When output slot 2 is used for fee compensation, the actual recipient of that note — whether designated by feeRecipientAddress or chosen by the prover when feeRecipientAddress == 0 — SHOULD receive enough offchain fee-note data to recompute commitment2 before broadcasting the transaction. Because the protocol does not validate payload semantics, a fee recipient cannot safely rely on opaque outputNoteData2 bytes alone as proof of payment.
Rationale
System Contract, Fork-Managed Outer Circuit, and No Admin Pause
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 outer verification key could drain the entire pool, so outer circuit upgrades require the same social consensus as any other protocol change. Inner circuits are permissionless because the outer 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.
Recursive Composition
Recursion separates pool-critical logic (outer circuit, fork-managed) from spend authorization (inner circuits, user-scoped; registry lifecycle operations remain address-gated). This enables permissionless auth extensibility: new signature schemes deploy as inner circuits without a hard fork. A malicious inner circuit can only risk the registering user’s funds, not the pool, because the outer circuit independently enforces value conservation, nullifiers, deterministic output randomness, and auth-policy checks — in practice, adding a new auth method is one registerAuthPolicy call with no fund transfers, no new addresses, and no anonymity set fragmentation. Existing auth methods remain active; unwanted methods can be deregistered (Section 6.4). The proving overhead vs a monolithic circuit is the cost of these properties. Decoupling the intent format from the protocol lets inner circuits evolve their signing formats independently, without coordination or a protocol change.
Specialized Proving and Wallet Compatibility
First-party proving is feasible today on commodity hardware — end-to-end proving takes ~20s with ~8 GB peak memory on desktop hardware (16 threads; Noir v1.0.0-beta.19 + Barretenberg 4.0.4). The protocol does not require specialized hardware for users who want to keep proof generation within infrastructure they control.
This EIP also supports non-custodial proof delegation: a user can outsource proof generation to a third party without outsourcing spending authority. The prover cannot steal funds, redirect payments, or forge unauthorized transactions. It can, however, emit unusable note-delivery payloads, making the in-flight transfer’s output notes unrecoverable by the recipient. Rotating outputSecret cuts off a former prover’s ability to derive future output randomness for that address. Because note delivery is not coupled to the proof system, the X-Wing baseline and any later delivery schemes can evolve without changing the proof relation.
Hybrid Delivery Baseline
No classical-only interoperable delivery scheme is defined. The baseline receive path uses the X-Wing X25519 + ML-KEM-768 hybrid KEM to avoid steering users toward note-delivery ciphertexts with known harvest-now-decrypt-later exposure while also hedging against failures in the post-quantum component. This choice is limited to note delivery; it does not make the overall protocol post-quantum.
Private Fee Compensation
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. If feeRecipientAddress is zero and feeAmount > 0, the prover chooses output slot 2’s owner 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.
Transaction-Time Auth-Method Anonymity
innerVkHash, authDataCommitment, and policyVersion are private inputs, never exposed as public inputs in transact. See Section 4 for the full auth-method anonymity model.
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 introduces new functionality via a system contract and precompiles and requires a network upgrade (hard fork). It does not change the meaning of existing transactions or contracts. No backward compatibility issues are known.
Test Cases
TBD.
Security Considerations
Multi-Auth Security Boundary
Every active (address, innerVkHash) pair is an independent spend authority for the same notes (Section 6.4). The user’s effective security is the minimum security of all active auth methods for that address. Registering a weak inner circuit alongside a strong one gives the security of the weak one. Users SHOULD deregister auth methods they no longer trust via deregisterAuthPolicy (Section 5.3). Deactivation is bounded-delay — the old root remains valid for AUTH_POLICY_ROOT_HISTORY_BLOCKS blocks.
DoS via Root History
Prolonged congestion can cause proofs against stale roots to fail before submission. The commitment root history is a fixed-size circular buffer (COMMITMENT_ROOT_HISTORY_SIZE entries) that advances on every transact; the user and auth policy registries use block-based windows (USER_REGISTRY_ROOT_HISTORY_BLOCKS and AUTH_POLICY_ROOT_HISTORY_BLOCKS respectively) where history entries are recorded on mutation and acceptance expires as blocks advance. Under sustained high throughput the commitment buffer is the binding constraint — users must submit proofs before the buffer wraps past their proven root.
Metadata Leakage
Deposits and withdrawals are public by design. Shielded transfer token and amount are private, but network-level metadata (timing, gas patterns, relayer behavior, transaction size) can still leak information. The constant 2-input/3-output shape with phantom/dummy slots mitigates some structural metadata leakage while reserving a fixed slot for optional fee compensation.
State Growth
The pool accumulates append-only state for commitments, nullifiers, and intent nullifiers. This is the main state-growth cost of the design relative to overwrite-in-place balance models: these values cannot be safely pruned without breaking spend or replay protection. All pool state, including this append-only state, is ordinary Ethereum contract state and remains subject to Ethereum’s general gas pricing and state-management trajectory.
Output Note Data Leakage
Empty or variable-size dummy payloads can leak which outputs are real. See Section 15 for payload guidance.
Auth Policy Registry Liveness
The block-based aging rule (at most one root history entry per block) prevents same-block churn from burning multiple history slots. An attacker making many registerAuthPolicy calls within a single block consumes at most one slot. However, an attacker can still churn across blocks by making a registration in every block over the window, filling the history with attacker-controlled roots. The buffer length bounds the cost of this attack — AUTH_POLICY_ROOT_HISTORY_BLOCKS blocks of sustained registrations. The buffer size is consensus-critical and MUST be pinned by the spec to prevent post-deployment changes that could shrink the revocation window.
Third-Party Prover Residual Visibility
A third-party prover permanently learns nullifierKey and can monitor spends of previously known notes indefinitely. It also learns the current outputSecret; rotating it via rotateOutputSecret cuts off future output randomness derivation after stale user roots expire. A compromised nullifierKey requires address migration; outputSecret compromise alone is recoverable through rotation.
Third-Party Prover Discretion
Because coin selection is delegated, a third-party prover can intentionally merge differently labeled notes and force MIXED_LABEL, degrading future provenance/selective-disclosure utility without violating payment semantics.