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 protocol-level private ETH and ERC-20 transfers with public deposits and withdrawals, implemented as a fixed-address 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 system contract has no on-chain upgrade mechanism and can only be replaced by a hard fork.

Motivation

Ethereum transactions and balances are public by default. This deters real demand from users and organizations that require basic financial privacy: payroll, treasury operations, institutional flows, and ordinary payments.

Ethereum has no canonical private transfer system. Private transfers are possible through app-layer pools, but multiple incompatible deployments coexist and none has emerged as the default target for wallet and infrastructure integration. This fragmentation hurts adoption and it hurts privacy, because splitting users across pools shrinks the anonymity set that each pool provides.

This EIP defines a canonical pool — one system contract at a fixed address — that the ecosystem can build against instead of choosing among incompatible pools.

Protocol enshrinement also resolves an upgradeability dilemma. An upgradeable app-layer contract relies on admin keys or governance tokens; a compromise can drain funds. An immutable contract cannot evolve: if the proof system weakens or cryptographic assumptions change, funds and privacy are trapped behind aging infrastructure. The canonical private transfer system defined by this EIP has no admin key and no on-chain upgrade path, yet can still evolve through Ethereum's existing social-consensus hard-fork process — the same mechanism that governs every other protocol change.

Ethereum already defines valid public asset transfers at the protocol layer. This EIP extends that model with a canonical validity layer for private asset transfers.

Scope

This EIP specifies the on-chain component: the pool contract, proof system, and registries. End-to-end transaction privacy 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:

  1. A system contract deployed at a fixed address, holding all shielded pool state (e.g., note commitment tree, nullifier set, intent-nullifier set, user registry).
  2. 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.
  3. A recursive proof composition separating auth (permissionless inner circuits) from protocol invariants (hard-fork-managed outer circuit).
  4. An auth policy registry binding (address, auth-method) pairs to credentials, supporting multiple auth methods per address.
  5. A public-input interface for proofs and required contract execution checks.
  6. A per-note label tracking deposit lineage.
  7. 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

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

3.3 Poseidon Hash Construction

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

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:

poseidon(USER_REGISTRY_LEAF_DOMAIN, uint160(user), nullifierKeyHash, outputSecretHash)

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.

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 any companion-standard note-delivery scheme.

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. 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 companion standard used. 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. Companion standards MAY define additional delivery keys for note recovery, but those are not protocol requirements of this EIP.

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. Companion standards MAY still define delivery-key rotation for wallet-layer note delivery, but that is separate from the protocol's outputSecret revocation path.

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

5.2 State

The pool MUST maintain:

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.

5.3 Contract Interface

The pool MUST expose the following functions:

Pool transaction:

struct PublicInputs {
    uint256 merkleRoot;
    uint256 nullifier0;
    uint256 nullifier1;
    uint256 commitment0;
    uint256 commitment1;
    uint256 commitment2;
    uint256 publicAmountIn;
    uint256 publicAmountOut;
    uint256 publicRecipientAddress;
    uint256 publicTokenAddress;
    uint256 depositorAddress;
    uint256 intentNullifier;
    uint256 registryRoot;
    uint256 validUntilSeconds;
    uint256 executionChainId;
    uint256 authPolicyRegistryRoot;
    uint256 outputNoteDataHash0;
    uint256 outputNoteDataHash1;
    uint256 outputNoteDataHash2;
}

function transact(
    bytes calldata proof,
    PublicInputs calldata publicInputs,
    bytes calldata outputNoteData0,
    bytes calldata outputNoteData1,
    bytes calldata outputNoteData2
) external payable;

User registration:

function register(
    uint256 nullifierKeyHash,
    uint256 outputSecretHash
) external

function registerFor(
    address user,
    uint256 nullifierKeyHash,
    uint256 outputSecretHash,
    uint256 userNonce,
    bytes calldata signature
) external

register is called by msg.sender to bind their address to a nullifier key hash and output-secret hash. registerFor allows a third party to register on behalf of user using an EIP-712 signature (see Section 6.2).

function rotateOutputSecret(
    uint256 newOutputSecretHash
) external

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

Auth policy registration:

function registerAuthPolicy(
    uint256 innerVkHash,
    uint256 authDataCommitment
) external

function registerAuthPolicyFor(
    address user,
    uint256 innerVkHash,
    uint256 authDataCommitment,
    uint256 userNonce,
    bytes calldata signature
) external

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.

registerAuthPolicyFor allows a third party to register on behalf of user using an EIP-712 signature. It performs the same canonicality checks, key computation, policyVersion increment, leaf write, and authPolicyNonce increment as registerAuthPolicy, using user in place of msg.sender.

Both functions MUST increment authPolicyNonce — this ensures a direct registration invalidates any outstanding delegated signatures. EIP-712 here is for delegated registration, not intent authorization.

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.

Both methods MUST emit:

event AuthPolicyRegistered(
    address indexed user,
    uint256 innerVkHash,
    uint256 authDataCommitment,
    uint256 policyVersion
);

Auth policy deregistration:

function deregisterAuthPolicy(
    uint256 innerVkHash
) external

deregisterAuthPolicy is called by msg.sender to remove an auth policy. The contract writes 0 (the empty leaf) at the auth-policy tree key uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, msg.sender, innerVkHash)) and increments authPolicyNonce[msg.sender]. Deregistration is direct-only — no delegated variant — to minimize the phishing surface for destructive operations. 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:

event AuthPolicyDeregistered(
    address indexed user,
    uint256 innerVkHash
);

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.

  1. Verify the proof via the verification precompile using proof and publicInputs.

  2. Verify execution chain ID. Require executionChainId == block.chainid.

  3. Enforce intent expiry.

  4. Require validUntilSeconds > 0.
  5. Require block.timestamp <= validUntilSeconds.
  6. Require validUntilSeconds <= block.timestamp + MAX_INTENT_LIFETIME.

  7. Check merkle root. Require merkleRoot equals the current commitment root or is in the commitment root history.

  8. Check registry root. Require registryRoot equals the current user registry root or is in the user registry root history. registryRoot MUST be nonzero.

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

  10. Enforce nullifier uniqueness. Require nullifier0 != nullifier1 (defense-in-depth). The contract MUST NOT attempt to distinguish phantom nullifiers from real ones.

  11. Mark nullifiers spent. Require both nullifiers are unspent; then mark them spent.

  12. Mark intent nullifier used. Require intentNullifier is unused; then mark it used.

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

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

  15. Enforce amount range. 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).

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

  17. Emit events. Emit the following event:

    solidity event ShieldedPoolTransact( uint256 indexed nullifier0, uint256 indexed nullifier1, uint256 indexed intentNullifier, uint256 commitment0, uint256 commitment1, uint256 commitment2, uint256 leafIndex0, uint256 postInsertionRoot, bytes outputNoteData0, bytes outputNoteData1, bytes outputNoteData2 );

    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 companion standards.

    Registration events:

    ```solidity event UserRegistered( address indexed user, uint256 nullifierKeyHash, uint256 outputSecretHash );

    event OutputSecretRotated( address indexed user, uint256 outputSecretHash );

    ```

    register and registerFor MUST emit UserRegistered. rotateOutputSecret MUST emit OutputSecretRotated. Scanners use these events to maintain local copies of the user registry tree.

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 register or the delegated registerFor flow). Withdrawal recipients (publicRecipientAddress) do not need to be registered — withdrawals send to any Ethereum address.

6.2 Registration Methods

The contract MUST provide:

All three 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 three methods MUST compute the resulting user-registry leaf and revert if it equals 0 — the zero leaf is reserved for the absent state.

The contract MUST maintain a per-user registrationNonce that increments on each successful registration and is included in the signed payload to prevent replay of old signatures.

The EIP-712 domain is { name: "ShieldedPool", version: "1", chainId: block.chainid, verifyingContract: SHIELDED_POOL_ADDRESS }. The typed struct is Register(address user, uint256 nullifierKeyHash, uint256 outputSecretHash, uint256 nonce). The contract MUST verify the signature, require nonce == registrationNonce[user], and increment registrationNonce[user] on success.

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) or registerAuthPolicyFor (delegated via EIP-712). See 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:

Adding a new auth method. To add a new auth method:

  1. Publish an inner circuit that verifies the new signature scheme and outputs [authorizingAddress, authDataCommitment, policyVersion, intentDigest].
  2. 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.
  3. 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.

7. Note Commitment and Nullifiers

7.1 Address and Amount Constraints

Inside the circuit:

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.

commitment = poseidon(
  amount,
  ownerAddress,
  randomness,
  nullifierKeyHash,
  tokenAddress,
  label
)

The binary-tree Poseidon construction and exact input ordering are defined in Section 3.3.

7.3 Nullifier

A real input note nullifier MUST be computed as:

nullifier = poseidon(NULLIFIER_DOMAIN, nullifierKey, leafIndex_u32, randomness)

7.4 Phantom Nullifier

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

phantom_nullifier = poseidon(PHANTOM_DOMAIN, nullifierKey, intentNullifier, slotIndex)

The contract MUST treat phantom nullifiers indistinguishably from real nullifiers.

7.5 Output Secret

The sender-side output secret MUST hash to:

outputSecretHash = poseidon(OUTPUT_SECRET_DOMAIN, outputSecret)

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:

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:

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:

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

Independent extension axes:

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:

  1. Recursively verifies the inner proof → outputs [authorizingAddress, authDataCommitment, policyVersion, intentDigest].
  2. Computes innerVkHash from innerVkey (Inner VK Hash). Proves auth policy membership at key uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash)).
  3. 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.
  4. Enforces authorizingAddress == depositorAddress — the signer must be the depositor.
  5. Binds authorizingAddress to the depositor's user registry entry (nullifierKeyHash, outputSecretHash).
  6. Derives intentNullifier from (nullifierKey, intentDigest) (Section 9.8).
  7. Proves the recipient's user registry entry using recipientAddress — obtains the recipient's nullifierKeyHash for output note commitment binding.
  8. Constrains output slot 0's ownerAddress to recipientAddress.
  9. Constrains output slot 2 to either a fee note (amount == feeAmount, owner determined per Section 9.5) or dummy.
  10. Enforces publicAmountIn == amount + feeAmount.
  11. 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:

  1. Recursively verifies the inner proof against innerVkey with public inputs [authorizingAddress, authDataCommitment, policyVersion, intentDigest].
  2. Computes innerVkHash from innerVkey (Inner VK Hash).
  3. 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.
  4. Computes canonical intent digest from execution data (Section 9.11), enforces intentDigest from inner outputs matches.
  5. Binds authorizingAddress to note ownership via user registry (nullifierKeyHash and outputSecretHash).
  6. Derives intentNullifier from (nullifierKey, intentDigest) (Section 9.8).

Inner Circuit Interface (normative):

Inner circuit public output vector — 4 field elements, fixed order:

  1. authorizingAddress — signer's address, identified by the inner circuit's auth process. MUST come from the inner circuit.
  2. authDataCommitment — credential commitment proved against. Outer circuit checks it matches registry leaf.
  3. 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.
  4. 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):

9.2 Note Ownership and Membership

For each input slot:

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

9.3 Nullifier-Key and Output-Secret Binding

For real input slots, the circuit MUST enforce:

poseidon(NK_DOMAIN, nullifierKey) == note.nullifierKeyHash

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.

In all operation modes, the circuit MUST enforce:

poseidon(OUTPUT_SECRET_DOMAIN, outputSecret) == registryOutputSecretHash(authorizingAddress)

where registryOutputSecretHash(authorizingAddress) is extracted from the sender's user-registry leaf. This binds deterministic output randomness to a rotatable sender-side secret.

9.4 Value Conservation

The circuit MUST enforce:

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

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

For output slot 2 specifically, the circuit MUST enforce:

Output note randomness MUST be deterministically derived for both real and dummy output slots:

randomness = poseidon(RANDOMNESS_DOMAIN, outputSecret, intentNullifier, slotIndex)

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:

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. Companion standards define the interpretation, including any encryption scheme, delivery-key rotation, versioning, or note-recovery logic.

9.8 Intent Nullifier

Transfer/withdrawal mode:

The outer circuit MUST enforce:

intentNullifier = poseidon(INTENT_DOMAIN, nullifierKey, intentDigest)

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:

intentNullifier = poseidon(INTENT_DOMAIN, nullifierKey, intentDigest)

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.

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.

intentDigest = poseidon(
    INTENT_DIGEST_DOMAIN,
    policyVersion,
    authorizingAddress,
    operationKind,
    poolAddress,
    tokenAddress,
    recipientAddress,
    amount,
    feeRecipientAddress,
    feeAmount,
    rawNonce,
    validUntilSeconds,
    executionChainId
)

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 != 0DEPOSIT_OP; depositorAddress == 0 AND publicAmountOut > 0WITHDRAWAL_OP; depositorAddress == 0 AND publicAmountOut == 0TRANSFER_OP.

Normative execution-field binding (MUST):

10. Public Inputs

The outer verifier's public-input vector is the 19 fields of PublicInputs (Section 5.3), in declaration order.

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.

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:

label = poseidon(
  LABEL_DOMAIN,
  executionChainId,
  depositorAddress,
  tokenAddress,
  publicAmountIn,
  intentNullifier
)

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

13. Poseidon Hash Contexts

Context Inputs (in order) Arity
Note commitment amount, ownerAddress, randomness, nullifierKeyHash, tokenAddress, label 6
Nullifier NULLIFIER_DOMAIN, nullifierKey, leafIndex_u32, randomness 4
Phantom nullifier PHANTOM_DOMAIN, nullifierKey, intentNullifier, slotIndex 4
Nullifier key hash NK_DOMAIN, nullifierKey 2
Output secret hash OUTPUT_SECRET_DOMAIN, outputSecret 2
Output randomness RANDOMNESS_DOMAIN, outputSecret, intentNullifier, slotIndex 4
Intent nullifier (all modes) INTENT_DOMAIN, nullifierKey, intentDigest 3
Canonical intent digest INTENT_DIGEST_DOMAIN, policyVersion, authorizingAddress, operationKind, poolAddress, tokenAddress, recipientAddress, amount, feeRecipientAddress, feeAmount, rawNonce, validUntilSeconds, executionChainId 13
Auth policy key (truncated to 160 bits) AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash 3
Auth policy leaf AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion 3
Inner VK hash AUTH_VK_DOMAIN, vk_elem_0, ..., vk_elem_{n-1} TBD
Deposit label LABEL_DOMAIN, executionChainId, depositorAddress, tokenAddress, publicAmountIn, intentNullifier 6
User registry leaf USER_REGISTRY_LEAF_DOMAIN, uint160(user), nullifierKeyHash, outputSecretHash 4
Merkle tree node left, right 2

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:

ShieldedPoolIntent(
    uint256 policyVersion,
    uint256 innerVkHash,
    uint8   operationKind,
    address tokenAddress,
    address recipientAddress,
    uint256 amount,
    address feeRecipientAddress,
    uint256 feeAmount,
    uint64  rawNonce,
    uint32  validUntilSeconds
)

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:

  1. Computes the EIP-712 signing hash from the struct and domain.
  2. Verifies the ECDSA signature against the provided secp256k1 public key (ecdsaPubKeyX, ecdsaPubKeyY). Derives authorizingAddress from the public key via keccak256(pubKeyX || pubKeyY)[12:].
  3. Reads all intent fields directly from the struct (no packing or extraction needed).
  4. Computes the canonical intent digest per Section 9.11.
  5. Outputs [authorizingAddress, authDataCommitment, policyVersion, intentDigest] where authDataCommitment = poseidon(ecdsaPubKeyX, ecdsaPubKeyY).

15. Output Note Data

outputNoteData0, outputNoteData1, and outputNoteData2 are opaque bytes emitted alongside the three output commitments in ShieldedPoolTransact. The protocol does not define an encryption scheme, delivery-key registry, or note-recovery procedure — those are defined by companion standards. The contract verifies the hash binding (Section 9.7) but MUST NOT decode or validate payload contents.

Companion standards SHOULD use constant-size real and dummy payloads to reduce structural leakage. Without a companion standard defining an interoperable note-delivery scheme, there is no canonical receive path.

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, wallets can adopt post-quantum delivery schemes without a hard fork.

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. Achieving full anonymity in an account model requires updating all N accounts per transaction, which is impractical on-chain. 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

Nullifiers are append-only and not safely pruneable — removing a nullifier would allow double-spends. Each transact call adds 3 permanent storage entries (2 nullifiers + 1 intent nullifier) plus 3 commitment tree leaves. The pool uses normal storage writes and is subject to Ethereum's general 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.

Copyright

Copyright and related rights waived via CC0.