EIP-8182 - Private ETH and ERC-20 Transfers

Created 2026-03-03
Status Draft
Category Core
Type Standards Track
Authors
Requires

Abstract

This EIP introduces private ETH and ERC-20 transfers via a shielded-pool system contract. The pool does not mandate a single spend-authorization method: each user registers their own (e.g., ECDSA signature, passkey). Beyond installing the shielded-pool system contract at fork activation, this EIP introduces no new precompile, opcode, transaction type, or other change to the Ethereum protocol.

Motivation

Sending assets publicly on Ethereum is straightforward. A user chooses ETH or a token, specifies a recipient using an Ethereum address or ENS name, and clicks send in an Ethereum wallet. Recipients, wallets, and applications already know how to interpret that transfer because they rely on the same shared standards.

Private transfers have no analogous shared default today, even though many ordinary financial activities require privacy. Payroll, treasury management, donations, and similar activities typically require that the sender, recipient, or amount not be globally visible. Without a shared private transfer layer, Ethereum cannot serve these use cases directly, so they are pushed toward traditional financial systems or other blockchains.

If private transfers are valuable, why has the market not produced a widely adopted default on Ethereum? Because a private transfer application cannot compete on product quality alone. Its effectiveness also depends on how many users and how much value share the same pool. A small pool offers weak privacy even for a superior product, while a large pool can remain attractive even when competing products are better. That means app-layer teams cannot focus only on wallet UX, authentication, compliance, or proof systems. They must also persuade users to deposit into their pool, which is difficult when the pool is not already large.

But growing the pool is only part of the problem. App-layer teams also have to decide how the pool changes over time. If the pool is upgradeable, the parties with the power to change it could compromise user funds. Immutable pools avoid that risk, but they cannot adapt as proof systems weaken or cryptographic assumptions change. Neither is a good foundation for common privacy infrastructure.

The Ethereum protocol should break this impasse by providing a shared privacy layer. This EIP does that by defining a protocol-managed private transfer system, updated only through Ethereum's hard-fork process, that provides a common pool for ETH and compatible ERC-20 tokens. Notes themselves bind to hidden owner identifiers; wallets resolve recipients to those identifiers off-chain. Applications can then build on that base without each having to bootstrap, govern, and defend their own pool.

Scope

This EIP specifies the on-chain component: the pool contract, proof system, and auth-policy registry. End-to-end transaction privacy still requires complementary infrastructure (note delivery, mempool encryption, network-layer anonymity, wallet integration) that is out of scope. Note delivery in particular is left to wallet coordination or companion standards; see Section 12.

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:

  1. A system contract deployed at a protocol-defined address, holding all shielded pool state (note-commitment tree, nullifier set, intent replay ID set, and auth-policy registry) with no proxy, no admin function, and no on-chain upgrade mechanism.
  2. A proof-free deposit path that inserts one note for a hidden owner-side commitment.
  3. A split-proof architecture for note spending: a fork-managed Groth16 BN254 pool proof verified by the system contract, plus an auth proof verified by a user-registered auth verifier contract via staticcall.
  4. A private auth-policy registry: a single mutable Merkle tree of leaves binding each address to its ownerNullifierKeyHash, noteSecretSeedHash, and current policy set, without publishing the user-to-verifier mapping.

These components are presented as a single EIP because they share state and form a single deployment unit.

2. Terminology

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 registry tree leaves
POLICY_COMMITMENT_DOMAIN policy_commitment Wallet-submitted auth-policy commitment
BLINDED_AUTH_COMMITMENT_DOMAIN blinded_auth_commitment Blinded auth commitments

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

3.3 Poseidon Hash Construction

This EIP uses Poseidon2 over the BN254 scalar field p (defined in Section 3.1) with the following parameters:

The single hash function used throughout this EIP is:

poseidon(x_1, ..., x_N) = Poseidon2_sponge(x_1, ..., x_N)

Poseidon2_sponge is defined as follows. Initialize the 4-element state to [0, 0, 0, N << 64], where N is the number of inputs. If N = 0, apply one Poseidon2 permutation to this initial state and return state element 0. Otherwise, partition the inputs into ⌈N/3⌉ chunks of 3 elements each, zero-padding the final chunk with 0 when N mod 3 ≠ 0. For each chunk [c_0, c_1, c_2] in order, compute state[j] ← (state[j] + c_j) mod p for j ∈ {0, 1, 2}, then apply one Poseidon2 permutation to the state. After all chunks are processed, return state element 0.

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).

Auth-policy registry tree. Depth-32 sparse mutable Poseidon Merkle tree, keyed by leafPosition (LSB-first). Each registered Ethereum address has exactly one assigned leafPosition (Section 6.1). Leaf value: poseidon(AUTH_POLICY_DOMAIN, uint160(user), ownerNullifierKeyHash, noteSecretSeedHash, policySetCommitment). Empty leaf is 0. The contract maintains a block-based root history with window AUTH_POLICY_ROOT_HISTORY_BLOCKS (Section 5.2.1); the pre-update root is recorded on every setAuthPolicy call.

Policy-set tree. Depth-POLICY_SET_DEPTH sparse Poseidon Merkle tree of policyCommitment values, keyed LSB-first like the other trees in this EIP, computed off-chain by the wallet to produce policySetCommitment (Section 6.1). Not maintained on-chain. Empty slots are 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 8.6. The system contract rejects any non-canonical public input; otherwise x and x + p would verify identically but map to different uint256 storage keys, enabling nullifier reuse or intent replay.

4. Architecture

This EIP uses a split-proof architecture that splits note spending into two independently-verified proofs with different trust properties.

Deposits are contract-native. Public deposits create notes directly through the pool contract. No proof is required for deposit insertion. The split-proof architecture below applies to transact, which spends existing private notes.

Pool proof (Groth16 BN254 SNARK, hard-fork-managed). There is exactly one pool circuit; its relation can only change via hard fork. It enforces all protocol invariants for transact: value conservation, nullifier derivation, Merkle membership, deterministic note-secret derivation for ordinary outputs, auth-policy registry leaf and policy-set membership checks, sender identity binding, blinded-auth-commitment recomputation, transaction-intent-digest recomputation, and token consistency. The system contract verifies this proof using the embedded verification key (Section 5.4.1 step 7). The pool circuit is the security boundary — a bug here can compromise all funds in the pool.

Auth proof (permissionless). Anyone can write and deploy an auth circuit and a corresponding authVerifier Solidity contract. It handles authentication — verifying the user's credential or policy — and intent parsing — computing the transaction intent digest over transaction fields, the chosen authVerifier, and any authorization-selected execution constraints. It outputs two public values: [blindedAuthCommitment, transactionIntentDigest]. The system contract dispatches the auth proof to the user-selected authVerifier via staticcall (Section 11).

Both proofs are verified in one transact call (pool within the system contract, auth via staticcall to authVerifier); both share [blindedAuthCommitment, transactionIntentDigest] taken from the pool's public inputs. Section 8.1 is the normative interface.

Responsibility Where enforced Fork required?
Value conservation, nullifier derivation, Merkle membership Pool Yes
Deterministic ordinary note-secret derivation Pool Yes
Auth-policy registry leaf membership Pool Yes
Policy-set membership Pool Yes
Sender identity binding Pool Yes
Intent replay ID, transaction-intent-digest, blinded-auth-commitment recomputation Pool Yes
Pool proof verification and auth verifier dispatch System contract Yes
Credential or policy authorization, intent parsing Auth No
Auth data commitment derivation, blinded auth commitment construction Auth No

A bug in the pool circuit risks every note; a bug in an auth circuit risks only identities with a policy registered for that authVerifier in any accepted authPolicyRoot.

5. System Contract

5.1 Deployment and Upgrade Model

The shielded pool is deployed as a system contract at SHIELDED_POOL_ADDRESS = 0x0000000000000000000000000000000000081820.

At the activation fork, clients MUST install a system-contract account at SHIELDED_POOL_ADDRESS implementing this specification. The exact bytecode is incorporated into client releases at activation time.

The verification-key byte layout, public-input layout, and pairing equation are normative in Section 5.5. The system contract embeds the verification key in its bytecode and verifies pool proofs against that embedded key. The verification key is fixed by a one-time multi-party trusted-setup ceremony for the pool circuit, the same pattern used for KZG in EIP-4844; the bytecode is finalized when that ceremony completes, which is why this EIP does not pin a specific bytecode.

5.2 State

The pool MUST maintain:

5.2.1 Auth-Policy Root History

The auth-policy registry tree's root history is block-based. For window W = AUTH_POLICY_ROOT_HISTORY_BLOCKS, 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 the auth-policy tree 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 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. 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 auth-policy state changes unless transaction ordering is controlled; the safer default is to wait at least one subsequent block before proving against the new root.

5.3 Contract Interface

The pool MUST expose the following functions.

Private-note spend path

struct PublicInputs {
    uint256 noteCommitmentRoot;
    uint256 nullifier0;
    uint256 nullifier1;
    uint256 noteBodyCommitment0;
    uint256 noteBodyCommitment1;
    uint256 noteBodyCommitment2;
    uint256 publicAmountOut;
    uint256 publicRecipientAddress;
    uint256 publicTokenAddress;
    uint256 intentReplayId;
    uint256 validUntilSeconds;
    uint256 executionChainId;
    uint256 authPolicyRoot;
    uint256 outputNoteDataHash0;
    uint256 outputNoteDataHash1;
    uint256 outputNoteDataHash2;
    uint256 authVerifier;
    uint256 blindedAuthCommitment;
    uint256 transactionIntentDigest;
}

function transact(
    bytes calldata poolProof,
    bytes calldata authProof,
    PublicInputs calldata publicInputs,
    bytes calldata outputNoteData0,
    bytes calldata outputNoteData1,
    bytes calldata outputNoteData2
) external

Public deposit path

function deposit(
    address token,
    uint256 amount,
    uint256 ownerCommitment,
    bytes calldata outputNoteData
) external payable

Read methods

function getCurrentRoots()
    external
    view
    returns (
        uint256 noteCommitmentRoot,
        uint256 authPolicyRoot
    )

function isAcceptedNoteCommitmentRoot(
    uint256 root
) external view returns (bool)

function isAcceptedAuthPolicyRoot(
    uint256 root
) external view returns (bool)

function isNullifierSpent(
    uint256 nullifier
) external view returns (bool)

function isIntentReplayIdUsed(
    uint256 intentReplayId
) external view returns (bool)

struct UserEntry {
    uint32 leafPosition;
    uint256 ownerNullifierKeyHash;
    uint256 noteSecretSeedHash;
    uint256 policySetCommitment;
}

function getAuthPolicyEntry(
    address user
) external view returns (
    bool registered,
    UserEntry memory entry
)

getCurrentRoots returns the current note-commitment root and the current auth-policy-registry root accepted by the contract.

isAcceptedNoteCommitmentRoot and isAcceptedAuthPolicyRoot return whether the supplied root would currently pass the same acceptance rule enforced by transact. isAcceptedAuthPolicyRoot(0) MUST return false.

isNullifierSpent returns whether the supplied nullifier has already been marked spent. isIntentReplayIdUsed returns whether the supplied intent replay ID has already been consumed.

getAuthPolicyEntry returns the registered state for user. registered is true iff userEntries[user].leafPosition != 0; for an unregistered address entry MUST be the zero-valued UserEntry.

Auth-policy registration

function setAuthPolicy(
    uint256 ownerNullifierKeyHash,
    uint256 noteSecretSeedHash,
    uint256 policySetCommitment
) external returns (uint256 leafPosition)

setAuthPolicy is called by msg.sender to register or update their auth-policy registry leaf. The caller computes policySetCommitment off-chain as the depth-POLICY_SET_DEPTH Merkle root over their currently active policyCommitment values, where each policyCommitment = poseidon(POLICY_COMMITMENT_DOMAIN, uint160(authVerifier), authDataCommitment, registrationBlinder). setAuthPolicy does not expose individual policyCommitment values, the authVerifiers, authDataCommitments, or registrationBlinders. (authVerifier is revealed at spend time as a public input to transact; see §5.4.1 step 8 and Metadata Leakage.)

Same-value calls (submitting the values already registered) are permitted. The contract still records the pre-update root and emits AuthPolicySet; the leaf write is a no-op.

To revoke all currently active policies, a caller submits policySetCommitment equal to the depth-POLICY_SET_DEPTH empty-set root. The contract performs no special-casing of this value; the in-circuit spend-side constraints make the resulting leaf state unspendable. Deactivation is delayed by the auth-policy root-history window (Section 6.1).

The contract MUST emit:

event ShieldedPoolTransact(
    uint256 indexed nullifier0,
    uint256 indexed nullifier1,
    uint256 indexed intentReplayId,
    address authVerifier,
    uint256 noteCommitment0,
    uint256 noteCommitment1,
    uint256 noteCommitment2,
    uint256 leafIndex0,
    uint256 postInsertionCommitmentRoot,
    bytes outputNoteData0,
    bytes outputNoteData1,
    bytes outputNoteData2
);

event ShieldedPoolDeposit(
    address indexed depositor,
    uint256 noteCommitment,
    uint256 leafIndex,
    uint256 amount,
    uint256 tokenAddress,
    uint256 postInsertionCommitmentRoot,
    bytes outputNoteData
);

event AuthPolicySet(
    address indexed user,
    uint256 ownerNullifierKeyHash,
    uint256 noteSecretSeedHash,
    uint256 policySetCommitment,
    uint256 leafPosition,
    uint256 leafValue,
    uint256 postUpdateAuthPolicyRoot
);

5.4 Execution

transact and deposit MUST each be non-reentrant.

5.4.1 transact

On each transact call, the pool MUST execute the following steps:

  1. Verify execution chain ID. Require executionChainId == block.chainid.
  2. Enforce intent expiry.
  3. Require validUntilSeconds > 0.
  4. Require block.timestamp <= validUntilSeconds.
  5. Require validUntilSeconds <= block.timestamp + MAX_INTENT_LIFETIME.

This is a submission-window bound, not a measure of time since signing. 3. Check note-commitment root. Require noteCommitmentRoot equals the current note-commitment root or is in the note-commitment root history. 4. Check auth-policy root. Require authPolicyRoot equals the current auth-policy-registry root or is in the auth-policy root history (Section 5.2.1). authPolicyRoot MUST be nonzero. 5. Enforce nullifier uniqueness. Require nullifier0 != nullifier1. The contract MUST NOT attempt to distinguish phantom nullifiers from real ones. 6. 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. 7. Verify the pool proof. Verify poolProof against publicInputs using the embedded Groth16 BN254 verification key per Section 5.5. Revert if any failure mode in Section 5.5 is hit. 8. 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 11). MUST revert if the staticcall reverts, returns non-32 bytes, or returns false. 9. Mark nullifiers spent. Require both nullifiers are unspent; then mark them spent. 10. Mark intent replay ID used. Require intentReplayId is unused; then mark it used. 11. Verify output note data hashes. For each i ∈ {0, 1, 2}, require (uint256(keccak256(outputNoteData_i)) mod p) == outputNoteDataHash_i (Section 8.6), binding the payloads to the proof. The contract MUST NOT otherwise interpret or validate payload contents. 12. 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. * Transfer (publicAmountOut == 0) * Require publicRecipientAddress == 0. * Require publicTokenAddress == 0. 13. Assign leaf indices and insert outputs; emit event. * Require nextLeafIndex + 3 <= 2^32. * Let leafIndex0 = nextLeafIndex. * Compute:

  ```
  noteCommitment0 = poseidon(NOTE_COMMITMENT_DOMAIN, noteBodyCommitment0, leafIndex0)
  noteCommitment1 = poseidon(NOTE_COMMITMENT_DOMAIN, noteBodyCommitment1, leafIndex0 + 1)
  noteCommitment2 = poseidon(NOTE_COMMITMENT_DOMAIN, noteBodyCommitment2, leafIndex0 + 2)
  ```

* Require all three final commitments are nonzero. Dummy outputs use nonzero dummy note commitments; inserting 0 is indistinguishable from the tree's empty leaf value.
* Push the pre-insertion root to note-root history.
* Insert the three final commitments in order.
* Emit `ShieldedPoolTransact`.

The pool proof is a fixed 256-byte Groth16 BN254 string encoding the canonical proof elements (A, B, C). Pool-proof verification MUST reject any malformed encoding.

ERC-20 calls in both transact and deposit MUST use the following exact semantics:

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:

  1. Range checks.
  2. Require amount > 0.
  3. Require amount < 2^248.
  4. Require ownerCommitment != 0.
  5. Require ownerCommitment < p (Section 3.5).
  6. Receive public assets.
  7. If token == address(0) (ETH): require msg.value == amount.
  8. 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.
  9. Assign leaf index. Require nextLeafIndex + 1 <= 2^32. Let leafIndex = nextLeafIndex.
  10. Compute commitments.

``` noteBodyCommitment = poseidon( NOTE_BODY_COMMITMENT_DOMAIN, ownerCommitment, amount, uint160(token) )

noteCommitment = poseidon( NOTE_COMMITMENT_DOMAIN, noteBodyCommitment, leafIndex ) ```

Require noteCommitment != 0. 5. Insert the note. * Push the pre-insertion root to note-root history. * Insert the final note commitment. 6. Emit ShieldedPoolDeposit.

The contract does not validate or decode outputNoteData. It does not prove or enforce on-chain that ownerCommitment corresponds to a registered address. The standard receive flow is:

  1. sender resolves the recipient's ownerNullifierKeyHash and any wallet-layer or companion-standard delivery information via off-chain discovery,
  2. sender chooses or derives noteSecret,
  3. sender computes ownerCommitment = poseidon(OWNER_COMMITMENT_DOMAIN, ownerNullifierKeyHash, noteSecret),
  4. sender calls deposit.

5.5 Pool Proof Verification

The system contract embeds the canonical Groth16 BN254 verification key and verifies pool proofs against that embedded key using the standard Groth16 verification equation. Replacing the verification key requires a hard fork.

6. Auth Policy Registry

6.1 Structure and Lifecycle

The auth-policy registry is a single depth-32 sparse mutable Poseidon Merkle tree, keyed by leafPosition. Each registered Ethereum address has exactly one assigned leafPosition. The leaf at position p is:

poseidon(
  AUTH_POLICY_DOMAIN,
  uint160(user),
  ownerNullifierKeyHash,
  noteSecretSeedHash,
  policySetCommitment
)

Root history. Block-based with window AUTH_POLICY_ROOT_HISTORY_BLOCKS (Section 5.2.1). Any update to a leaf records the pre-update root in history; spends may prove against the current root or any root still within the window.

Identity binding. The first call to setAuthPolicy from an address assigns its leafPosition, locks ownerNullifierKeyHash, and registers the global uniqueness index entry. The triple (address, leafPosition, ownerNullifierKeyHash) is permanent for the lifetime of the identity. To rotate ownerNullifierKeyHash, a user MUST register a new identity from a fresh address and migrate notes by issuing transfers from the old identity to the new — the protocol provides no shortcut.

Mutable fields. noteSecretSeedHash and policySetCommitment may both change on subsequent setAuthPolicy calls. Rotation of either takes effect only after the pre-rotation auth-policy root ages out of the root-history window: until then, spends against historical roots remain valid using the prior leaf state. Wallets MUST retain the prior noteSecretSeed after rotation until the window expires and any in-flight transactions have settled or been abandoned.

Policy-set commitment. policySetCommitment is a depth-POLICY_SET_DEPTH sparse Merkle root over the user's currently active policyCommitment values, computed off-chain. Each policyCommitment = poseidon(POLICY_COMMITMENT_DOMAIN, uint160(authVerifier), authDataCommitment, registrationBlinder) is hidden inside policySetCommitment; registration does not expose authVerifier, authDataCommitment, or registrationBlinder. (authVerifier is revealed at spend time as a public input to transact.) Adding an auth method, removing one, or revoking all are all the same operation: compute the new policySetCommitment and call setAuthPolicy. To revoke all policies, submit the depth-POLICY_SET_DEPTH empty-set root. The pool circuit (Section 8) enforces that the spend's policyCommitment is nonzero and has a valid Merkle path in policySetCommitment; against an empty-set root, no nonzero leaf has a valid path, so no spend can succeed.

Wallets pick slot positions within the policy-set tree; the protocol does not canonicalize slot assignment. Duplicate policyCommitment entries are permitted but serve no purpose. To revoke a policy, the new policySetCommitment MUST exclude every slot containing that policy's policyCommitment; leaving a duplicate behind leaves the policy effective.

Cross-method note compatibility. Note commitments bind to ownerNullifierKeyHash, not to any specific policyCommitment. A note created when one method was used is spendable through any other method currently in the address's policySetCommitment.

Adding a new auth method. Publish the auth circuit and its authVerifier Solidity contract per Section 11. Wallets compute the new policySetCommitment over the address's existing policyCommitment values plus the new one and call setAuthPolicy. No hard fork.

Wallet-side state. A wallet retains, per active policy, enough metadata to reproduce that policy's policyCommitment and its position in the policy-set tree. Losing this metadata for a specific policy makes that policy unusable for spending; the user can include or replace it in a future setAuthPolicy call. Note ownership is unaffected because notes bind to ownerNullifierKeyHash. After rotating noteSecretSeed, wallets MUST also retain the prior seed until the root-history window expires.

Deactivation semantics. A setAuthPolicy call that removes a policy or rotates the seed leaves the prior leaf valid through accepted historical roots for up to AUTH_POLICY_ROOT_HISTORY_BLOCKS blocks. Spends mounted against an older root within the window continue to use the prior policy set and noteSecretSeed. After the window, only the current leaf state is reachable. Wallets SHOULD treat any rotation or revocation as taking full effect only after the window expires. Users planning a post-quantum migration or other security-motivated rotation MUST rotate sufficiently in advance of the threat materializing.

Lifecycle gating. setAuthPolicy is msg.sender-gated. Users who want multisig or contract-governed lifecycle control SHOULD use a smart-contract wallet address.

7. Note Commitment and Nullifiers

7.1 Address and Amount Constraints

Inside the pool circuit for transact:

Contract-side, the pool MUST reject:

7.2 ownerNullifierKeyHash

ownerNullifierKeyHash MUST be computed as:

ownerNullifierKeyHash = poseidon(
  OWNER_NULLIFIER_KEY_HASH_DOMAIN,
  ownerNullifierKey
)

ownerNullifierKeyHash is the hidden ownership identifier bound into notes.

7.3 ownerCommitment

The owner-side note commitment MUST be computed as:

ownerCommitment = poseidon(
  OWNER_COMMITMENT_DOMAIN,
  ownerNullifierKeyHash,
  noteSecret
)

ownerCommitment hides both ownerNullifierKeyHash and noteSecret from on-chain observers. On deposit, the contract treats ownerCommitment as an uninterpreted uint256 — it does not derive ownerNullifierKeyHash or noteSecret from it and does not verify its construction. On transact, it is a private witness reconstructed inside the pool circuit from the spender's ownerNullifierKeyHash and the note's noteSecret.

7.4 Note Body Commitment

The semantic note commitment MUST be computed as:

noteBodyCommitment = poseidon(
  NOTE_BODY_COMMITMENT_DOMAIN,
  ownerCommitment,
  amount,
  tokenAddress
)

This binds the note's owner-side fragment, amount, and token.

7.5 Final Note Commitment

The final inserted note commitment MUST be computed as:

noteCommitment = poseidon(
  NOTE_COMMITMENT_DOMAIN,
  noteBodyCommitment,
  leafIndex
)

leafIndex is the sequential note-tree leaf index assigned by the contract at insertion time. This is the structural uniqueness source for notes.

7.6 Nullifier

A real input note nullifier MUST be computed as:

nullifier = poseidon(
  NULLIFIER_DOMAIN,
  noteCommitment,
  ownerNullifierKey
)

This formula is mode-agnostic: it applies to notes created by deposit and to notes created by transact.

7.7 Phantom Nullifier

If an input slot is phantom, the circuit MUST use:

phantomNullifier = poseidon(
  PHANTOM_NULLIFIER_DOMAIN,
  ownerNullifierKey,
  intentReplayId,
  inputIndex
)

The contract MUST treat phantom nullifiers indistinguishably from real nullifiers.

7.8 Note Secret Seed

The note-secret seed MUST hash to:

noteSecretSeedHash = poseidon(
  NOTE_SECRET_SEED_DOMAIN,
  noteSecretSeed
)

noteSecretSeed is the source of deterministic randomness for the user's transact-output note secrets (Section 7.9); deposit noteSecret is wallet-chosen. The seed is rotatable through setAuthPolicy (Section 6.1); rotation takes full effect after the pre-rotation auth-policy root ages out of the root-history window. During the window, spends against historical roots continue to derive output secrets from the prior seed, so wallets MUST retain the prior seed until the window expires. Rotation does not affect ownership of existing notes (those bind to ownerNullifierKeyHash, not to the seed).

7.9 Note Secret

noteSecret is the per-note hidden blinder. Wallets MUST NOT reuse noteSecret across notes they create, because reuse creates linkability. Nullifier safety does not depend on noteSecret uniqueness in this design because structural note uniqueness comes from leafIndex.

For ordinary transact outputs, the circuit MUST derive:

noteSecret = poseidon(
  TRANSACT_NOTE_SECRET_DOMAIN,
  noteSecretSeed,
  intentReplayId,
  outputIndex
)

Here outputIndex is 0, 1, or 2.

For deposits, the depositor chooses noteSecret using any recoverable wallet-side rule or randomness and conveys it to the recipient through outputNoteData or out-of-band coordination. The contract does not validate noteSecret or its derivation. Standardized wallet-side derivations MAY be defined by companion ERCs.

8. Pool Circuit Requirements

8.1 Pool Circuit Interface and Auth Proof Coupling

The pool circuit MUST:

  1. open the auth-policy registry leaf at the witnessed leafPosition against authPolicyRoot, where the opened leaf equals poseidon(AUTH_POLICY_DOMAIN, uint160(authorizingAddress), ownerNullifierKeyHash, noteSecretSeedHash, policySetCommitment). The opened leaf is the sole source for ownerNullifierKeyHash, noteSecretSeedHash, and policySetCommitment used downstream,
  2. enforce the range constraint leafPosition < 2^32 (Section 7.1),
  3. recompute policyCommitment = poseidon(POLICY_COMMITMENT_DOMAIN, uint160(authVerifier), authDataCommitment, registrationBlinder) and enforce policyCommitment != 0,
  4. prove policyCommitment is a member of policySetCommitment via a depth-POLICY_SET_DEPTH Merkle path per Section 3.4,
  5. recompute blindedAuthCommitment = poseidon(BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor) and enforce equality with public input blindedAuthCommitment,
  6. recompute transactionIntentDigest per Section 8.9 and enforce equality with public input transactionIntentDigest,
  7. derive intentReplayId per Section 8.7 and enforce that the derived value equals public input intentReplayId,
  8. validate input note ownership and nullifiers,
  9. validate output note-body commitments and output bindings,
  10. enforce value conservation and token consistency.

authVerifier, blindedAuthCommitment, and transactionIntentDigest are public inputs (Section 9). ownerNullifierKeyHash, noteSecretSeedHash, policySetCommitment, policyCommitment, 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 11) MUST prove knowledge of the auth data committed by authDataCommitment, the canonical authDataCommitment derivation from that auth data, and satisfaction of a verifier-defined authorization relation that binds every transactionIntentDigest input (Section 8.9) plus blindingFactor, such that:

  1. 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.
  2. public output 0 equals poseidon(BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor);
  3. public output 1 equals the Section 8.9 formula (which excludes blindingFactor);
  4. 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 8). This is the cross-proof coupling; neither proof verifies the other directly. Nonce and blinding-factor freshness are wallet obligations (Security Considerations).

8.2 Input Ownership and Membership

For each input slot:

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).

8.3 Sender ownerNullifierKeyHash and Note-Secret-Seed Binding

In all spend modes, the circuit MUST enforce:

poseidon(OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey) == ownerNullifierKeyHash

where ownerNullifierKeyHash is the value extracted from the opened auth-policy registry leaf (Section 8.1). ownerNullifierKey is a single pool-circuit witness reused across all real input slots, this recomputation, and phantom-nullifier derivation. The circuit MUST NOT instantiate per-slot ownerNullifierKey witnesses.

The circuit MUST also enforce:

poseidon(NOTE_SECRET_SEED_DOMAIN, noteSecretSeed) == noteSecretSeedHash

where noteSecretSeedHash is similarly extracted from the opened leaf. This binds ordinary output note-secret derivation to the sender's currently registered seed.

8.4 Value Conservation

The circuit MUST enforce:

sum(input_amounts) == sum(output_amounts) + publicAmountOut

Both sides MUST include range checks to prevent overflow.

8.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:

Then:

Additional per-mode constraints. The sender's ownerNullifierKeyHash is the value extracted from the opened auth-policy registry leaf (Section 8.1).

For output slot 2 specifically:

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 and output assignment are not canonicalized.

8.6 Output Note Data and Output Binding

outputNoteDataHash0, outputNoteDataHash1, and outputNoteDataHash2 are public inputs that bind opaque note-delivery payloads to the proof. They are computed as outputNoteDataHash_i = uint256(keccak256(outputNoteData_i)) mod p, where p is the BN254 scalar field order (Section 3.1). The mod p reduction is required because each public input must be a canonical BN254 scalar field element (Section 3.5), and a raw keccak256 output can exceed p. The prover and the contract independently compute this value and verify equality.

For each slot i, the pool circuit MUST compute:

outputBinding_i = poseidon(
  OUTPUT_BINDING_DOMAIN,
  noteBodyCommitment_i,
  outputNoteDataHash_i
)

Execution constraints MAY lock any subset of these outputBinding_i values. If a slot is locked, the prover cannot change either the semantic note contents or the emitted payload bytes for that slot after authorization. The final inserted noteCommitment includes a contract-assigned leaf index and is therefore not itself the authorization-lock target.

The pool and auth circuits do not validate encryption scheme semantics or delivery format.

8.7 Intent Replay ID

All private-note spends use the same intent replay ID derivation:

intentReplayId = poseidon(
    INTENT_REPLAY_ID_DOMAIN,
    ownerNullifierKey,
    authorizingAddress,
    executionChainId,
    nonce
)

Reusing the same nonce within the same (ownerNullifierKey, authorizingAddress, executionChainId) replay domain makes those authorizations mutually exclusive even when their payment fields or execution constraints differ. Wallets MUST choose a fresh uniformly-random nonce with at least 128 bits of entropy for each new authorization.

The derived intentReplayId MUST equal public input intentReplayId.

8.8 Token Consistency

All real input and output notes MUST use the same tokenAddress.

8.9 Transaction Intent Digest

The auth circuit authenticates this digest; the pool circuit recomputes it from witnesses, public inputs, and mode-derived values and enforces equality.

transactionIntentDigest = poseidon(
    TRANSACTION_INTENT_DIGEST_DOMAIN,
    authVerifier,
    authorizingAddress,
    operationKind,
    tokenAddress,
    recipientOwnerNullifierKeyHash,
    amount,
    feeNoteRecipientOwnerNullifierKeyHash,
    feeAmount,
    publicRecipientAddress,
    executionConstraintsFlags,
    lockedOutputBinding0,
    lockedOutputBinding1,
    lockedOutputBinding2,
    nonce,
    validUntilSeconds,
    executionChainId
)

The pool circuit MUST derive operationKind from the public execution mode:

Normative execution-field binding

8.10 Execution Constraints

Execution constraints let an authorization optionally bind finalized output slots without changing the nonce-based replay domain. The authorization-bound fields executionConstraintsFlags, lockedOutputBinding0, lockedOutputBinding1, and lockedOutputBinding2 are inputs to transactionIntentDigest (Section 8.9).

9. Public Inputs

The pool proof's public-input vector is the 19 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.

executionConstraintsFlags, lockedOutputBinding0, lockedOutputBinding1, lockedOutputBinding2, nonce, authDataCommitment, blindingFactor, recipientOwnerNullifierKeyHash, and feeNoteRecipientOwnerNullifierKeyHash are private authorization-bound values checked inside the proof relation. ownerNullifierKeyHash, noteSecretSeedHash, policySetCommitment, policyCommitment, registrationBlinder, and leafPosition are private registry witnesses.

9.1 Public Input Range Validation

Every public input MUST be a canonical BN254 scalar field element (< p); the system contract rejects any non-canonical value (Section 5.5). In addition, the system contract enforces the following per-field range checks at Section 5.4.1 step 6: publicAmountOut < 2^248; publicRecipientAddress < 2^160, publicTokenAddress < 2^160, authVerifier < 2^160, authVerifier != 0; validUntilSeconds < 2^32; executionChainId < 2^32. These checks prevent non-address values aliasing into EVM-address slots and prevent amount overflow in the balance equation.

10. Poseidon Hash Contexts

Inputs are listed in declaration order. Each input is a single BN254 scalar field element (Section 3.5); the Section 3.3 length-tagged sponge consumes them in 3-element chunks. Arity is the number of input field elements (excluding length-tag bookkeeping inside the sponge state).

Context Inputs (in order) Arity
ownerNullifierKeyHash OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey 2
ownerCommitment OWNER_COMMITMENT_DOMAIN, ownerNullifierKeyHash, noteSecret 3
noteBodyCommitment NOTE_BODY_COMMITMENT_DOMAIN, ownerCommitment, amount, tokenAddress 4
noteCommitment NOTE_COMMITMENT_DOMAIN, noteBodyCommitment, leafIndex 3
Nullifier NULLIFIER_DOMAIN, noteCommitment, ownerNullifierKey 3
Phantom nullifier PHANTOM_NULLIFIER_DOMAIN, ownerNullifierKey, intentReplayId, inputIndex 4
Note secret seed hash NOTE_SECRET_SEED_DOMAIN, noteSecretSeed 2
Ordinary note secret TRANSACT_NOTE_SECRET_DOMAIN, noteSecretSeed, intentReplayId, outputIndex 4
Intent replay ID INTENT_REPLAY_ID_DOMAIN, ownerNullifierKey, authorizingAddress, executionChainId, nonce 5
Transaction intent digest TRANSACTION_INTENT_DIGEST_DOMAIN, authVerifier, authorizingAddress, operationKind, tokenAddress, recipientOwnerNullifierKeyHash, amount, feeNoteRecipientOwnerNullifierKeyHash, feeAmount, publicRecipientAddress, executionConstraintsFlags, lockedOutputBinding0, lockedOutputBinding1, lockedOutputBinding2, nonce, validUntilSeconds, executionChainId 17
Output binding OUTPUT_BINDING_DOMAIN, noteBodyCommitment, outputNoteDataHash 3
Policy commitment POLICY_COMMITMENT_DOMAIN, authVerifier, authDataCommitment, registrationBlinder 4
Auth policy leaf AUTH_POLICY_DOMAIN, user, ownerNullifierKeyHash, noteSecretSeedHash, policySetCommitment 5
Blinded auth commitment BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor 3
Merkle tree node left, right 2

Address-typed inputs are absorbed as uint160 field elements; uint32-typed inputs as uint32 field elements; amount and feeAmount carry an additional in-circuit < 2^248 constraint (Section 7.1).

11. 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 included in their policySetCommitment.

11.1 Interface

An auth verifier contract MUST implement:

interface IAuthVerifier {
    function verifyAuth(
        bytes calldata publicInputs,
        bytes calldata proof
    ) external returns (bool);
}

11.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 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 identities with a policy registered for that verifier in any accepted authPolicyRoot; 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.

12. Output Note Data

Note delivery, meaning how senders convey enough information for recipients to recover output notes, is not specified by this EIP. Wallets MAY coordinate delivery out of band, and companion standards MAY define shared registries or encryption formats. This EIP treats outputNoteData bytes as opaque. In transact, outputNoteData_i is hash-bound as defined in Section 8.6; in deposit, outputNoteData is emitted opaquely and is not proof-bound.

Rationale

System Contract, Fork-Managed Pool Circuit, and No Admin Pause

The pool is a protocol-managed account at a fixed address because its security depends on global state, not on a single application. The system contract has no upgrade key, no proxy, and no pause path. Changes require a hard fork.

A bug in the ZK scheme can compromise funds held in the pool but does not alter consensus rules, the validator set, or ETH supply semantics. Native integration (e.g., EIP-7503) can expose the protocol itself to ZK-scheme failures, including unbounded minting. The ZK-scheme risk to depositors is equivalent to existing app-level pools.

A deposit-only pause triggered by a consensus-layer flag was considered and rejected. Any pause trigger reintroduces a governance surface; a withdrawal freeze during a false alarm locks user funds pending a hard fork to unpause. The scope of a soundness exploit (pool-held funds only, not protocol consensus) makes the hard-fork remediation timeline acceptable relative to the governance risk of a pause mechanism.

Split Proof Architecture and Private Auth-Policy Registration

Managing specific auth methods at the protocol level would freeze users to one scheme or require a hard fork per addition. Splitting authorization out into permissionless authVerifier contracts lets methods evolve without protocol changes. The blinded registration is what keeps adding a method from fragmenting the anonymity set: each (authVerifier, authDataCommitment) pair is hidden inside policyCommitment under registrationBlinder, and blindingFactor rerandomizes blindedAuthCommitment each transaction so it cannot be linked back to a specific registration.

Credential-Proof Separation

The auth circuit consumes the user's authorization credential (typically a signature) as a witness; the pool circuit never sees it. Producing the credential is orders of magnitude cheaper than producing the pool proof, so the device that authorizes a spend does not have to be the device that proves it. This enables hardware wallets (sign on a constrained device, prove on a capable one), delegated proving services (hand a credential plus state witnesses to a third-party prover without giving up signing authority), and async signing flows (sign now, prove later when a capable device is available). A combined single-circuit design that required the signer to also generate the proof would foreclose all of these.

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.

Pool-proof verification gas is dominated by ECADD / ECMUL / ECPAIRING calls on the 19 public-input scalar multiplications and the final pairing.

Future PQ Migration

Groth16 over BN254 is not post-quantum secure. Two PQ adaptations are available together:

  1. The proof system is fork-swappable via system contract code replacement, and the on-chain state schema (tree shapes, domain tags, preimage layouts, public-input layout, intent format) is defined independently of the proof system. A future verifier consuming the same logical relation accepts the same state. The swap is a single hard-fork event.
  2. Users can rotate to PQ-secure auth methods well before quantum capability materializes: register a PQ policyCommitment, call setAuthPolicy with a policySetCommitment containing only PQ methods, then wait for the auth-policy root-history window to expire. After the window, no classical-method proof can land. Notes do not need to be moved; only the policy set rotates.

A post-quantum proof system was not selected for the pool at activation because even at aggressive parameters a direct STARK proof exceeds the practical L1 calldata target (>167 KB in our measurements). This EIP therefore preserves both verifier-swap and per-identity policy-rotation paths rather than imposing post-quantum proving costs at activation.

Hidden Owner IDs and Address Scope

Notes commit to hidden ownerNullifierKeyHash values; recipients are identified at the protocol layer by ownerNullifierKeyHash, not by Ethereum address. Wallets resolve addresses to ownerNullifierKeyHash off-chain via companion-standard discovery. The Ethereum address remains the authorization-and-administration namespace through the auth-policy registry (setAuthPolicy is msg.sender-gated and the leaf binds user = msg.sender), but the address does not appear inside note commitments or as a private recipient field. The one Ethereum address that surfaces in the protocol is publicRecipientAddress, which names the EVM-level destination of a withdrawal and is bound into transactionIntentDigest.

Proof-Free Deposits

Deposits are public asset movements into the pool. Making them contract-native avoids spending proof overhead where no private-note input is being consumed. Private-note spending still requires a proof and remains the hard security boundary.

Constrained vs Wallet-Chosen Note Secrets

transact and deposit treat noteSecret differently by design. In transact, an unconstrained noteSecret would give the prover discretion over note openings and recovery-sensitive randomness. The pool circuit therefore pins noteSecret = poseidon(TRANSACT_NOTE_SECRET_DOMAIN, noteSecretSeed, intentReplayId, outputIndex), tying it to the authorizing address's registered seed, the authorization nonce, and the output slot. In deposit, there is no prover to discipline: the depositor constructs the note directly and conveys whatever noteSecret they chose to the recipient via outputNoteData or out of band. Nullifier uniqueness no longer depends on noteSecret structure — the contract-assigned leafIndex carries that role — so removing the protocol-level derivation from the deposit path does not weaken any safety invariant.

Two-Layer Note Commitment

Splitting note creation into ownerCommitment, noteBodyCommitment, and final noteCommitment lets ordinary private-note spends preserve privacy while letting deposits and contract-completed flows finalize note insertion with a contract-assigned leaf index. Output locking binds the semantic note (noteBodyCommitment) plus payload hash rather than the insertion-specific final leaf.

Leaf-Index Uniqueness

Using the assigned leaf index in the final note commitment guarantees uniqueness even when two notes share the same semantic contents. This removes nullifier-collision dependence on note-secret derivation structure while still requiring wallets to avoid note-secret reuse for privacy.

Out-of-Protocol Compliance

This EIP does not include any in-protocol compliance primitives — origin tags, allowlist identifiers, risk scores, or provenance propagation rules. Encoding a specific compliance model at the protocol layer is less expressive than what can be built on top, commits the protocol to one model prematurely, and makes the compliance surface subject to hard-fork governance rather than companion-standard iteration. Disclosure formats and compliance workflows belong in companion standards and off-chain infrastructure built over the public deposit and withdrawal record.

Finalized Output Binding

outputBinding = poseidon(OUTPUT_BINDING_DOMAIN, noteBodyCommitment, outputNoteDataHash) binds one emitted semantic note commitment to one output-note-data hash. Execution constraints use this binding to lock finalized output slots.

Private Fee Compensation

The system contract charges no protocol-level fee. The protocol's mandatory onchain cost is Ethereum gas. Prover or broadcaster compensation, if any, is optional and user-authorized via output slot 2 rather than imposed by the pool.

Private transfers need a way to compensate a broadcaster or sponsor without revealing the transferred token on-chain. A public fee output would leak the asset for shielded transfers, so this EIP reserves output slot 2 for an optional private fee note. The authorization binds feeNoteRecipientOwnerNullifierKeyHash directly: wallets resolve the fee recipient off-chain and include their ownerNullifierKeyHash in the signed intent. The circuit enforces that output slot 2's owner-hash equals the bound value. Keeping fee compensation inside the same note model also makes the design compatible with both legacy transactions and future transaction types that separate sender from gas payer: the transaction layer can decide who submits and who pays gas, while the pool continues to express compensation as an ordinary shielded note in the transferred asset.

UTXO-Based Notes over Account-Based Encrypted Balances

Account-based encrypted balances reveal access patterns — which accounts transact and how frequently — even when amounts are hidden. UTXO-based notes avoid this: spending a note produces new commitments, and shielded transfers within the pool reveal nothing about amounts, tokens, or counterparties on-chain.

Backwards Compatibility

This EIP defines a new system contract and a new pool proof relation, all activated by the same hard fork. It does not modify the semantics of existing contracts or existing ERC-20 interfaces.

Test Cases

Normative test coverage MUST include at least:

Implementations SHOULD additionally test:

Security Considerations

Multi-Auth Security Boundary

Each policyCommitment in an address's policySetCommitment is an independent spend-authorization path for notes bound to that address's ownerNullifierKeyHash. Including a weak auth method 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 includes an auth policy for an authVerifier address in their policySetCommitment 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 identities with a policy registered for that specific address in any accepted authPolicyRoot are at risk, and only for spends that go through that auth verifier. Other auth policies registered by the same user 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.1) keeps the user-to-verifier mapping off-chain, so the apparent anonymity set seen by an observer is every registered identity rather than only the users of the visible authVerifier. The actual sender set is a subset of that (identities with a policy registered for this authVerifier in any accepted authPolicyRoot), but observers cannot collapse apparent to actual without breaking registrationBlinder. Output note data may leak metadata depending on the delivery scheme and wallet payload conventions in use.

Chain-Level Linkability of Self-Reshield Flows

A self-reshield flow — transact withdrawal to a public helper contract, public swap or other public execution, then deposit of the result back into the pool — is chain-level-linkable even though the reshielded note itself is private. The withdrawing EOA, the swap, and the deposit call are all public transactions attributable to the same initiator, and their composition is observable.

The privacy property this flow provides is post-swap anonymity: the reshielded note joins the general note anonymity set and its eventual spend is indistinguishable from any other private-note spend. The flow does not make the swap itself private, and it does not delink the initiator from the act of shielding. Any atomic external swap against a public venue has this property regardless of the shielded-pool design.

State Growth

The pool accumulates append-only state for note commitments, nullifiers, and intent replay IDs. These values cannot be safely pruned without breaking spend or replay protection.

Output Note Data Leakage and Sabotage

outputNoteData payloads are opaque and on-chain. Their size and structure can leak metadata: empty or variable-size dummy payloads can leak which outputs are real. A malicious sender, prover, or coordinator can also emit unusable outputNoteData and make note recovery fail. This cannot steal funds or redirect payment, but it can break recipient recovery. Wallet-layer or companion-standard delivery formats SHOULD use constant-size payloads to limit structural leakage.

Auth-Policy Root History and Deactivation Delay

The auth-policy registry uses a block-based root history with window AUTH_POLICY_ROOT_HISTORY_BLOCKS (Section 5.2.1). The at-most-one-entry-per-block aging rule prevents same-block churn from burning multiple history slots; an attacker churning updates across blocks can fill history with attacker-controlled roots without affecting other users' ability to spend against any in-window legitimate root. Updates to an address's leaf — revoking policies, rotating noteSecretSeed, or changing the auth method set — are not instantaneous: the pre-update root remains accepted for up to AUTH_POLICY_ROOT_HISTORY_BLOCKS blocks, during which spends against historical roots continue to use the prior policy set and seed. Users planning a post-quantum migration or other security-motivated rotation MUST rotate sufficiently in advance of the threat. Wallets that need cancellation semantics tighter than the window SHOULD rely on short validUntilSeconds windows and nonce consumption.

Registration Hygiene

Losing (authVerifier, authDataCommitment, registrationBlinder) for a specific policy makes that policy unusable for spending but does not affect fund access — notes bind to ownerNullifierKeyHash, not to any single auth policy — so the user can register a fresh policy under the same identity by recomputing policySetCommitment and calling setAuthPolicy. nonce and blindingFactor MUST be drawn from a cryptographic RNG with at least 128 bits of effective entropy: low-entropy nonce allows a digest-preimage brute force; low-entropy blindingFactor plus a guessable authDataCommitment deanonymizes the registered auth data from blindedAuthCommitment.

noteSecret Reuse

Reusing noteSecret across notes does not by itself create nullifier collisions in this design because nullifiers are derived from final note commitments that include the assigned leaf index. It does, however, create linkability and degrades privacy. Wallets MUST avoid noteSecret reuse.

Deposits Are Permissionless

The contract accepts opaque ownerCommitment values on deposit and does not require the recipient to have called setAuthPolicy. An unregistered recipient may register later before spending. Senders resolve the recipient's ownerNullifierKeyHash and any delivery information off-chain via a companion standard. Recipients SHOULD claim their ownerNullifierKeyHash via setAuthPolicy before publishing it externally; the contract enforces global uniqueness, and a published unclaimed ownerNullifierKeyHash can be permanently claimed by any address calling setAuthPolicy first, locking the original generator out.

Unlocked Output Slots

If an authorization leaves an output slot unlocked (Section 8.10), the prover may choose that slot's outputNoteData and any otherwise-unpinned note details subject to the pool circuit's normal constraints. This cannot steal funds or override authorized payment fields, but malformed or unrecoverable outputNoteData can break recipient recovery. Authorizations that need finalized slot contents pinned SHOULD lock the corresponding outputBinding.

Pool Proof System Assumptions

Soundness rests on a one-time multi-party trusted-setup ceremony for the canonical pool circuit: at least one participant must honestly destroy their toxic-waste contribution. Verifier upgrades altering the pool circuit's R1CS shape MUST run a fresh ceremony. Under quantum adversaries, commitments and nullifiers (254-bit Poseidon2/BN254) sit at ≈2^111 BHT for the dominant note-commitment-tree multi-target preimage at depth-32 saturation, with nullifier collisions at the ≈2^84 BHT floor (DoS-only, second spend reverts). The outputNoteDataHash_i mod p reduction (Section 8.6) is bias-negligible. The pool proof system is classical Groth16 BN254 (future PQ migration in Rationale).

Copyright

Copyright and related rights waived via CC0.