---
eip: 8182
title: Private ETH and ERC-20 Transfers
description: A canonical validity layer for private ETH and compatible ERC-20 transfers via a system contract and verifier precompile.
author: Tom Lehman (@RogerPodacter)
discussions-to: https://ethereum-magicians.org/t/eip-8182-private-eth-and-erc-20-transfers/27889
status: Draft
type: Standards Track
category: Core
created: 2026-03-03
requires: 20
---

## Abstract

This EIP introduces protocol-level private ETH and compatible [ERC-20](./eip-20.md) transfers with public deposits and withdrawals, implemented as a system contract with a companion proof-verification precompile. A recursive proof architecture separates protocol invariants enforced by a hard-fork-managed outer circuit from permissionless inner authentication circuits, allowing users to choose compatible authentication methods — such as ECDSA, passkeys, or multisig — without requiring a hard fork for each new auth method. The same system contract also exposes an optional delivery-key registry for standardized on-chain note-delivery key discovery and supports opt-in origin-tagged notes for later app-defined origin proofs. Receiving shielded notes requires prior user registration, even when note delivery is handled out of band. The system contract has no on-chain upgrade mechanism and can only be replaced by a hard fork.

## Motivation

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

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

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

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

The Ethereum protocol should break this impasse by providing a shared privacy layer. This EIP does that by defining a protocol-managed private transfer system, updated only through Ethereum's hard-fork process, that provides a common pool for ETH and compatible ERC-20 tokens, supports private transfers to registered Ethereum addresses through an opt-in registry-backed receive path, and exposes an optional origin-tagged mode for applications that want later origin proofs. Applications can then build on that base without each having to bootstrap, govern, and defend their own pool.

### Scope

This EIP specifies the on-chain component: the pool contract, proof system, registries, and one baseline note-delivery scheme. The delivery-key registry is optional and only standardizes one on-chain discovery path. End-to-end transaction privacy still requires complementary infrastructure (mempool encryption, network-layer anonymity, wallet integration) that is out of scope.

## Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

### 1. Overview

This EIP defines:

1. A **system contract** deployed at a protocol-defined address, holding all shielded pool state (e.g., note commitment tree, nullifier set, transaction replay ID set, user registry, delivery-key registry) with no proxy, no admin function, and no on-chain upgrade mechanism.
2. A **recursive proof composition** separating auth (permissionless inner circuits) from protocol invariants (hard-fork-managed outer circuit).
3. An **auth policy registry** binding `(address, innerVkHash)` pairs to auth credentials, supporting multiple auth policies per address.
4. An **optional delivery-key registry** binding each address to one active registered note-delivery endpoint.
5. A **public-input interface** for proofs and required contract execution checks.
6. An **optional per-note origin tag** for notes that need later origin proofs.
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.

### 2. Terminology

* **Note**: A shielded UTXO-like object represented on-chain by a `noteCommitment`.
* **Note commitment**: The public on-chain commitment / identifier for one note. See Section 7.2.
* **Nullifier**: Generic term for a spent-note marker published by the protocol. Real input notes publish `noteNullifier`; phantom inputs publish `phantomNullifier`.
* **Note nullifier**: The public spent-note marker for one real input note. See Section 7.3.
* **Phantom nullifier**: The public spent-input marker for one phantom input slot. See Section 7.4.
* **Origin tag**: A per-note tag. `originTag != 0` means the note is origin-tagged and traces back to one originating deposit. `originTag == 0` means the note is not origin-tagged. Here, origin means the note's originating deposit, not the immediate transfer sender or the full transfer history.
* **Proving infrastructure**: Infrastructure that generates zero-knowledge proofs. May be first-party (local machine, self-hosted server) or third-party (a proving service). See Section 4.1.
* **Outer circuit**: The hard-fork-managed circuit that enforces protocol invariants: value conservation, nullifiers, Merkle membership, deterministic note-secret derivation for outputs, inner proof verification, and auth policy registry checks.
* **Inner circuit**: A permissionless circuit that handles authentication and intent parsing. Outputs `[authDataCommitment, transactionIntentDigest]`. `authorizingAddress` and `policyVersion` are supplied to the outer circuit as private witnesses and authenticated through `transactionIntentDigest`. See Section 9.1.
* **Auth policy**: A binding of `innerVkHash`, `authDataCommitment`, and `policyVersion` in the auth policy registry. Each address may have multiple auth policies, one per inner circuit. See Sections 5.2 and 6.4.
* **Transaction intent digest**: The canonical digest of the contemplated pool action. It includes the signer-authenticated transaction fields and signer-selected execution constraints. The inner circuit authenticates this digest from the signed intent fields and any companion-standard constants; the outer circuit recomputes the same formula from witnesses, public inputs, and mode-derived values.
* **Transaction replay ID**: The transaction-level replay identifier consumed on use. It shares the replay domain inputs across all outputs from one transaction. See Section 9.8.
* **policyVersion**: A monotonically increasing counter per (address, innerVkHash) pair. Authenticated by the inner circuit's signed artifact. See Section 6.4.
* **Phantom input**: A dummy input slot used to maintain constant arity (2-input circuit) while spending only one real note. An observer MUST NOT be able to distinguish phantom from real inputs.
* **Dummy output**: A dummy output slot used to maintain constant output count (3 outputs) while producing fewer real notes.
* **User registry**: A Merkleized mapping from `address` to `(ownerNullifierKeyHash, noteSecretSeedHash)`. Leaf format: `poseidon(USER_REGISTRY_LEAF_DOMAIN, uint160(user), ownerNullifierKeyHash, noteSecretSeedHash)` (Section 3.4).
* **Owner nullifier key**: The note owner's non-rotatable hidden note-ownership key. It is hashed as `ownerNullifierKeyHash = poseidon(OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey)` in notes and in the user registry. See Sections 7.3 and 9.8.
* **Owner nullifier key hash**: The hash commitment to `ownerNullifierKey`, used in note commitments and the user registry. See Sections 7.2 and 9.3.
* **Note secret seed**: A rotatable sender-side secret committed in the user registry as `noteSecretSeedHash = poseidon(NOTE_SECRET_SEED_DOMAIN, noteSecretSeed)`. Used only to derive future `noteSecret`s, and therefore indirectly for future real-note-nullifier derivation, not for note ownership or wallet-layer note delivery.
* **Note secret**: The per-note hidden value derived during output formation and later reused in `noteNullifier`. See Sections 7.2, 7.3, and 9.5.
* **Delivery endpoint**: A public `(schemeId, keyBytes)` pair registered by an address for note delivery in the delivery-key registry. `schemeId = 0` denotes no active registered endpoint in this registry. The contract stores endpoints opaquely and does not validate that `keyBytes` are well-formed for the selected scheme.
* **Output note data**: Opaque per-output bytes emitted by the contract for wallet/app-layer note delivery. The base protocol does not validate or interpret these bytes. Section 15 defines the scheme-`1` interpretation. Delivery may also be coordinated out of band.
* **Output binding**: `poseidon(OUTPUT_BINDING_DOMAIN, noteCommitment, outputNoteDataHash)`. This binds one emitted note commitment to one output-note-data hash for signer-authenticated finalized-output locking.
* **Execution constraints**: The private signed fields `executionConstraintsFlags` and `lockedOutputBinding0/1/2`. They optionally bind finalized output slots.
* **`depositorAddress`**: Public input. The deposit payer's Ethereum address; `msg.sender` must equal it. Nonzero selects deposit mode.
* **`recipientAddress`**: Private witness in the transaction intent digest. The recipient authorized by the signer — the note owner for transfers and deposits, or the withdrawal destination (constrained to equal `publicRecipientAddress`).
* **`feeRecipientAddress`**: Private witness in the transaction intent digest. The optional designated recipient of the private fee note in output slot 2. If `feeAmount > 0` and `feeRecipientAddress == 0`, the prover chooses output slot 2's nonzero `ownerAddress` at proof generation time.
* **`feeAmount`**: Private witness in the transaction intent digest. The optional private fee paid through output slot 2. `0` means no fee.
* **`originMode`**: Private witness in the transaction intent digest. `0` means default origin handling; `1` means tagged-origin handling. In deposit mode this selects whether the deposit creates untagged notes (`0`) or origin-tagged notes (`1`). In transfer/withdrawal mode, tagged-origin handling requires the real inputs to share one nonzero `originTag` and requires any real outputs to remain origin-tagged.
* **`nonce`**: Private signed field element used for replay protection.
* **`executionConstraintsFlags`**: Private signed bitmask selecting which finalized-output slots are locked by the signer.
* **`lockedOutputBinding0/1/2`**: Private signed field elements that optionally lock `outputBinding0/1/2` for slots 0, 1, and 2.
* **`publicRecipientAddress`**: Public input. The withdrawal destination address; zero for deposits and transfers.

### 3. Parameters and Constants

#### 3.1 Domain Separators

All Poseidon hashes that require domain separation MUST include a distinct domain tag (field element). Each domain tag is derived as:

```
DOMAIN = uint256(keccak256("eip-8182.<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 fixes all domain tags.

The following domain tags are defined by this EIP (all use the `eip`-`8182.` prefix):

| Constant | Context string | Usage |
|----------|---------------|-------|
| `NOTE_NULLIFIER_DOMAIN` | `note_nullifier` | Real note nullifiers |
| `PHANTOM_NULLIFIER_DOMAIN` | `phantom_nullifier` | Phantom nullifiers |
| `ORIGIN_TAG_DOMAIN` | `origin_tag` | Deposit origin tags |
| `TRANSACTION_REPLAY_ID_DOMAIN` | `transaction_replay_id` | Transaction replay IDs |
| `OWNER_NULLIFIER_KEY_HASH_DOMAIN` | `owner_nullifier_key_hash` | Owner nullifier key hashing |
| `NOTE_SECRET_DOMAIN` | `note_secret` | Deterministic note-secret derivation |
| `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 leaves |
| `AUTH_POLICY_KEY_DOMAIN` | `auth_policy_key` | Auth policy registry tree keys |
| `AUTH_VK_DOMAIN` | `auth_vk` | Inner circuit VK hashing |
| `NOTE_SECRET_SEED_DOMAIN` | `note_secret_seed` | Note secret seed hashing |
| `USER_REGISTRY_LEAF_DOMAIN` | `user_registry_leaf` | User registry leaves |

All values are deterministically computable from the derivation formula above and MUST be `< p`.

#### 3.2 Fixed Constants

* `MAX_INTENT_LIFETIME = 86400` — maximum allowed forward offset from `block.timestamp` to `validUntilSeconds`, in seconds (24 hours), checked at submission time. This means proofs are accepted only during the final 24 hours before expiry; it does not measure authorization age from signing time. Root-history windows independently bound proof freshness.
* `NOTE_COMMITMENT_ROOT_HISTORY_SIZE = 500` — consensus-critical, fixed by spec.
* `USER_REGISTRY_ROOT_HISTORY_BLOCKS = 500` — consensus-critical, fixed by spec.
* `AUTH_POLICY_ROOT_HISTORY_BLOCKS = 64` — consensus-critical, fixed by spec. The contract accepts the current auth policy root or any root preserved from the last 64 blocks. See Section 5.2.
* `DUMMY_OWNER_NULLIFIER_KEY_HASH` — `poseidon(OWNER_NULLIFIER_KEY_HASH_DOMAIN, 0xdead)`. Used for dummy output slots. The circuit enforces `amount == 0` for dummy outputs, preventing value extraction regardless of preimage knowledge.
* `TRANSFER_OP = 0` — operation kind for shielded transfers.
* `WITHDRAWAL_OP = 1` — operation kind for withdrawals.
* `DEPOSIT_OP = 2` — operation kind for deposits.
* `ORIGIN_MODE_DEFAULT = 0` — default origin handling.
* `ORIGIN_MODE_REQUIRE_TAGGED = 1` — tagged-origin handling. Deposits create origin-tagged notes; transfer/withdrawal mode requires the real inputs to share one nonzero `originTag` and any real outputs to remain origin-tagged.
* `LOCK_OUTPUT_BINDING_0 = 1 << 0` — lock output slot 0's finalized output binding.
* `LOCK_OUTPUT_BINDING_1 = 1 << 1` — lock output slot 1's finalized output binding.
* `LOCK_OUTPUT_BINDING_2 = 1 << 2` — lock output slot 2's finalized output binding.

#### 3.3 Poseidon Hash Construction

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

* State width: `t = 3` (2-arity, absorbing 2 field elements per permutation)
* S-box: `x^5` (`α = 5`)
* Full rounds: `R_F = 8`
* Partial rounds: `R_P = 57`
* Round constants and MDS matrix: exactly the constants in [the Poseidon parameter asset](../assets/eip-8182/poseidon_bn254_t3_rf8_rp57.json). The corresponding normative vectors are in [the Poseidon vector asset](../assets/eip-8182/poseidon_vectors.json).

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.

**Note 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_NOTE_COMMITMENT[i + 1] = hash_2(EMPTY_NOTE_COMMITMENT[i], EMPTY_NOTE_COMMITMENT[i])` with `EMPTY_NOTE_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), ownerNullifierKeyHash, noteSecretSeedHash)`

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 note-secret derivation for outputs, 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 the transaction intent digest over transaction fields and any signer-selected execution constraints. It outputs two public values: `[authDataCommitment, transactionIntentDigest]`. The outer circuit uses private witnesses for `authorizingAddress` and `policyVersion`, authenticates them through `transactionIntentDigest`, checks `authDataCommitment` against the auth-policy leaf, and checks `transactionIntentDigest` against its own independent recomputation from execution data plus finalized-output bindings. 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 note-secret derivation | Outer | Yes |
| Inner proof verification | Outer | Yes |
| Auth policy registry check | Outer | Yes |
| Transaction replay ID derivation | Outer | Yes |
| Canonical transaction-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). The signer MAY additionally lock a slot's emitted note commitment to its payload hash through `outputBinding`. The inner circuit has no scheme-specific role in note delivery, and the outer circuit does not enforce any encryption scheme or delivery format. Section 15 defines the registry lookup and the scheme-`1` interpretation.

#### 4.1 Proving Modes

Proof generation can be delegated to a third party without granting spending authority. This section uses *first-party* and *third-party* to describe who is trusted to operate the prover; *local* and *remote* (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 `ownerNullifierKey`, `noteSecretSeed`, coin selection, witness construction, and note-delivery key lookup plus any supported delivery schemes.

**Third-party proving.** The user signs an authorization and delegates proof generation to a specialized proving service. The prover learns all transaction details and retains discretion over coin selection and registry root selection within the valid history window. Without `originMode = ORIGIN_MODE_REQUIRE_TAGGED`, a malicious prover can intentionally choose inputs that clear origin tags on real outputs. With `originMode = ORIGIN_MODE_REQUIRE_TAGGED`, proofs fail unless deposits create nonzero origin tags and transfer/withdrawal mode uses real inputs with one shared nonzero `originTag` while keeping any real outputs origin-tagged. It cannot forge unauthorized operations, redirect payments, or extract funds — these properties are enforced by the proof system regardless of prover behavior. If the signer leaves a slot unlocked, a malicious prover can still choose unusable `outputNoteData` or mutate that slot's finalized output at proving time; if the signer locks a slot via `lockedOutputBinding0/1/2`, the prover cannot mutate that slot after signing.

|  | On-chain | Third-party prover |
|---|---|---|
| Tx occurred | yes | yes |
| Token | deposits and withdrawals | yes |
| Amount | deposits and withdrawals | yes |
| Fee amount | no | yes |
| Fee recipient | no | yes |
| Sender | deposits | yes |
| Recipient | withdrawals | yes |
| Which notes spent | no | yes |
| Auth method used | no | yes |

Shielded transfer public inputs reveal nothing beyond the fact that a transaction occurred. Opaque note-delivery payloads (`outputNoteData0`, `outputNoteData1`, `outputNoteData2`) are also on-chain; their size and structure may leak metadata depending on the delivery scheme and wallet payload conventions in use. Deposits expose depositor, token, and amount; the note recipient is private. Withdrawals expose amount, recipient, and token. `feeAmount` and the fee note's recipient remain private in all modes; if `feeRecipientAddress == 0` and `feeAmount > 0`, the prover chooses output slot 2's owner at proof generation time. Auth method used is hidden at the proof level for all pool transactions; auth policy registration is public. For deposits, because `depositorAddress` is public, observers can narrow the auth method to that address's registered auth-policy set. With first-party proving, the "Third-party prover" column does not apply.

Users MUST maintain independent backups of `ownerNullifierKey` and either `noteSecretSeed` or note plaintext including `noteSecret`. Loss of `ownerNullifierKey` is permanent fund loss. Loss of `noteSecretSeed` without note plaintext backups can make notes whose `noteSecret` has not otherwise been recovered unspendable. Users relying on delivery keys for note recovery SHOULD also retain the corresponding delivery private keys until all notes encrypted to them have been recovered.

**Third-party prover persistence.** A third-party prover learns `ownerNullifierKey` permanently and therefore retains the ability to monitor spends of previously known notes. It also learns the current `noteSecretSeed`, so it can derive future `noteSecret`s until that seed is rotated. After `rotateNoteSecretSeed` and stale user roots expire, the old prover can no longer derive note secrets for future transactions by that address. Delivery keys are separate wallet-layer material; rotating or removing a delivery key does not affect note ownership or proof validity, but old delivery private keys may still be needed to recover notes created before the rotation.

### 5. System Contract

#### 5.1 Deployment and Upgrade Model

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

At the activation fork, clients MUST install the account object in [the shielded-pool state dump](../assets/eip-8182/shielded-pool-state.json) at `SHIELDED_POOL_ADDRESS`. This state dump is the canonical shielded-pool activation artifact. The installer used to generate it is non-normative tooling and is not part of consensus.

The shielded-pool runtime is linked against an external `PoseidonT3` contract at `0x3333333C0A88F9BE4fd23ed0536F9B6c427e3B93`. Correct execution of the pool requires the code at that address to exactly equal [`poseidon_t3_runtime.hex`](../assets/eip-8182/poseidon_t3_runtime.hex). This external dependency is a prerequisite, not part of the shielded-pool activation artifact. This EIP does not constrain that account's balance, nonce, or storage. Chains adopting this EIP MUST NOT activate it unless that prerequisite is already satisfied at activation.

* The code at `SHIELDED_POOL_ADDRESS` can only be replaced by a subsequent hard fork that sets new code as part of its state transition rules.
* There is no proxy, no admin function, and no on-chain upgrade mechanism.
* Storage persists across fork-initiated code replacements (see Section 5.2).

#### 5.2 State

The pool MUST maintain:

* **Note commitment Merkle tree** — append-only Poseidon Merkle tree (depth: 32, ~4B leaves). Empty leaf = 0. Holds multi-asset notes (`tokenAddress` is inside the note commitment). The contract MUST revert if `nextLeafIndex + 3 > 2^32` (since `transact` always inserts three note commitments).
* **Note commitment root history** — circular buffer (size: `NOTE_COMMITMENT_ROOT_HISTORY_SIZE`, consensus-critical). On each `transact`, the contract MUST push the pre-insertion note-commitment root into this buffer. The contract accepts the **current root** OR any historical root still in the buffer.
* **Nullifier set** — `mapping(uint256 => bool)`.
* **Transaction replay ID set** — `mapping(uint256 => bool)`.
* **User registry** — depth-160 sparse Poseidon Merkle tree (Section 3.4), with block-based root history (window: `USER_REGISTRY_ROOT_HISTORY_BLOCKS`). History mechanics are defined in Section 5.2.1. The contract accepts the **current root** OR any historical root still within the window. Leaves commit to both `ownerNullifierKeyHash` and `noteSecretSeedHash`.
* **Delivery key registry** — `mapping(address => DeliveryEndpoint)` storing one active public registered delivery endpoint `(schemeId, keyBytes)` per registered address. This registry is optional, is not Merkleized, is not referenced by any circuit, has no root history, and does not affect proof validity.
* **Auth policy registry** — depth-160 sparse Poseidon Merkle tree (Section 3.4), with block-based root history (window: `AUTH_POLICY_ROOT_HISTORY_BLOCKS`). History mechanics are defined in Section 5.2.1. The contract accepts the **current root** OR any historical root still within the window. Used by the outer circuit for inner circuit binding.
* **Policy versions** — `mapping(bytes32 => uint256)` keyed by `keccak256(abi.encodePacked(user, innerVkHash))`, tracking the per-(address, innerVkHash) `policyVersion` counter. This mapping is the canonical source of truth for the next version to assign; the leaf value encodes the version at the time of its last write. Stored versions MUST remain canonical BN254 field elements (`< p`) because `policyVersion` is hashed into Poseidon-based leaves and digests. Both are updated atomically in `registerAuthPolicy`.

#### 5.2.1 Block-Based Registry Root Histories

The user registry and auth policy registry use block-based root histories. For a registry with window `W`, the contract maintains a ring buffer of `W + 1` `(root, blockNumber)` pairs. The extra slot prevents a mutation in block `N + W` from overwriting a root that is still within the acceptance window.

On the first mutation to a registry in block `N`, the contract MUST snapshot the root accepted at the start of block `N` into the ring buffer at position `N mod (W + 1)` with `blockNumber = N`. Subsequent mutations to the same registry in block `N` update the current root but MUST NOT create additional history entries.

A candidate root `r` is accepted iff there exists a stored pair `(storedRoot, storedBlockNumber)` such that `storedRoot == r` and `block.number - storedBlockNumber <= W`. The current root is always accepted.

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 `registerUser` / `rotateNoteSecretSeed` / `registerAuthPolicy` / `deregisterAuthPolicy` 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:

**Pool transaction:**

```solidity
struct PublicInputs {
    uint256 noteCommitmentRoot;
    uint256 nullifier0;
    uint256 nullifier1;
    uint256 noteCommitment0;
    uint256 noteCommitment1;
    uint256 noteCommitment2;
    uint256 publicAmountIn;
    uint256 publicAmountOut;
    uint256 publicRecipientAddress;
    uint256 publicTokenAddress;
    uint256 depositorAddress;
    uint256 transactionReplayId;
    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;
```

**Read methods:**

```solidity
function getCurrentRoots()
    external
    view
    returns (
        uint256 noteCommitmentRoot,
        uint256 registryRoot,
        uint256 authPolicyRegistryRoot
    )

function getUserRegistryEntry(
    address user
) external view returns (
    bool registered,
    uint256 ownerNullifierKeyHash,
    uint256 noteSecretSeedHash
)

function getAuthPolicy(
    address user,
    uint256 innerVkHash
) external view returns (
    bool active,
    uint256 authDataCommitment,
    uint256 policyVersion
)

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

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

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

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

function isTransactionReplayIdUsed(
    uint256 transactionReplayId
) external view returns (bool)
```

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

`getUserRegistryEntry` returns the current user-registry entry for `user`, or `(false, 0, 0)` if the address is not registered.

`getAuthPolicy` returns whether the `(user, innerVkHash)` pair is currently active plus the current `authDataCommitment` and `policyVersion` for that pair. It MUST reject `innerVkHash >= p` (BN254 scalar field order) to avoid the same field-aliasing ambiguity as `registerAuthPolicy`/`deregisterAuthPolicy`. If the pair was never registered, it returns `(false, 0, 0)`. After deregistration, it returns `(false, lastAssignedAuthDataCommitment, lastAssignedPolicyVersion)`.

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

`isNullifierSpent` returns whether the supplied nullifier has already been marked spent. `isTransactionReplayIdUsed` returns whether the supplied transaction replay ID has already been consumed.

These read methods are the canonical online read path for current state and status checks used by wallets, provers, relayers, and sponsors. Note-commitment-tree sync, note discovery, and witness construction remain off-chain event/indexer workflows; the spec MUST NOT require replay from genesis as the only standard read path.

**User registration:**

```solidity
function registerUser(
    uint256 ownerNullifierKeyHash,
    uint256 noteSecretSeedHash
) external

function registerUser(
    uint256 ownerNullifierKeyHash,
    uint256 noteSecretSeedHash,
    uint32 schemeId,
    bytes calldata keyBytes
) external
```

The two `registerUser` overloads are called by `msg.sender` to bind the caller's address to an owner-nullifier-key hash and note-secret-seed hash. The 4-argument overload also sets the caller's initial registered delivery endpoint atomically with registration.

```solidity
function rotateNoteSecretSeed(
    uint256 newNoteSecretSeedHash
) external
```

`rotateNoteSecretSeed` is called by `msg.sender` to update only the `noteSecretSeedHash` 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).

**Delivery key registration:**

```solidity
struct DeliveryEndpoint {
    uint32 schemeId;
    bytes keyBytes;
}

function setDeliveryKey(
    uint32 schemeId,
    bytes calldata keyBytes
) external

function removeDeliveryKey() external

function getDeliveryKey(
    address user
) external view returns (uint32 schemeId, bytes memory keyBytes)
```

`setDeliveryKey` is called by `msg.sender` to set or replace the caller's active registered delivery endpoint. The caller MUST already have a user-registry entry. `schemeId` MUST be nonzero and `keyBytes.length` MUST be nonzero. The contract stores `keyBytes` opaquely and MUST NOT validate that they are well-formed for the selected scheme.

`removeDeliveryKey` is called by `msg.sender` to clear the caller's active registered delivery endpoint. The caller MUST already have a user-registry entry. The contract MUST revert if no delivery endpoint is currently set.

`getDeliveryKey` returns the active registered delivery endpoint for `user`, or `(0, "")` if none is registered in this registry.

Delivery-key changes do not affect in-flight proofs, proof validity, or any root-history acceptance rule.

**Auth policy registration:**

```solidity
function registerAuthPolicy(
    uint256 innerVkHash,
    uint256 authDataCommitment
) external
```

`registerAuthPolicy` is called by `msg.sender` to bind the `(address, innerVkHash)` pair to an auth data commitment. `authDataCommitment` is opaque. The caller MUST already have a user-registry entry. A single address may register multiple auth policies (one per `innerVkHash`); each has its own independent `policyVersion`.

* MUST reject `innerVkHash >= p` or `authDataCommitment >= p` (BN254 scalar field order) to prevent field aliasing between the Poseidon tree key (which reduces mod `p`) and the keccak-based `policyVersion` mapping key (which does not).
* Computes the auth-policy tree key as `uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, msg.sender, innerVkHash))` (low 160 bits; see Section 3.4).
* Computes the per-pair version key as `keccak256(abi.encodePacked(msg.sender, innerVkHash))` and increments `policyVersion` for that pair (starting from 1 on first registration).
* MUST revert if the incremented `policyVersion >= p` before computing the new leaf, because `policyVersion` is consumed as a field element inside Poseidon-based leaves and intent digests.
* Computes the leaf `poseidon(AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion)`.
* MUST revert if the leaf equals 0 — the zero leaf is reserved for the absent/deregistered state (see `deregisterAuthPolicy`).
* Writes the leaf at the composite key.

**Root history update:** On every auth-policy registration or deregistration, the contract MUST ensure the block-based root history invariant (Section 5.2.1) is maintained.

The method MUST emit:

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

**Auth policy deregistration:**

```solidity
function deregisterAuthPolicy(
    uint256 innerVkHash
) external
```

`deregisterAuthPolicy` is called by `msg.sender` to remove an auth policy. The contract MUST reject `innerVkHash >= p` (BN254 scalar field order) to prevent field aliasing at the auth-policy tree key. The contract writes `0` (the empty leaf) at the auth-policy tree key `uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, msg.sender, innerVkHash))`. Deregistration is direct-only. After stale auth-policy roots expire, no proof against that (address, innerVkHash) pair can succeed. MUST revert if the leaf is already `0`. MUST emit:

```solidity
event AuthPolicyDeregistered(
    address indexed user,
    uint256 innerVkHash
);
```

After deregistration, the auth-policy tree state is indistinguishable from "never registered" — history is carried by events, not the current leaf. The auxiliary `getAuthPolicy` mapping MUST still expose the last assigned `authDataCommitment` and `policyVersion` for current-state introspection, but the tree leaf is `0` and therefore unusable for proof verification. 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.

Both delivery-key methods MUST emit:

```solidity
event DeliveryKeySet(
    address indexed user,
    uint32 indexed schemeId,
    bytes keyBytes
);

event DeliveryKeyRemoved(
    address indexed user,
    uint32 indexed schemeId
);
```

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)`, where `authDataCommitment` comes from the inner proof output and `authorizingAddress` and `policyVersion` are the same private witness values used in `transactionIntentDigest`; 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.**
   * Require `validUntilSeconds > 0`.
   * Require `block.timestamp <= validUntilSeconds`.
   * Require `validUntilSeconds <= block.timestamp + MAX_INTENT_LIFETIME`.

   This is a submission-window bound, not a measure of time since signing.

4. **Check note-commitment root.** Require `noteCommitmentRoot` equals the current note-commitment root or is in the note-commitment root history.

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

6. **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.

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

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

9. **Mark transaction replay ID used.** Require `transactionReplayId` is unused; then mark it used.

10. **Insert note commitments.** Insert `noteCommitment0`, `noteCommitment1`, and `noteCommitment2` into the note commitment tree. Note commitments MUST be nonzero — dummy outputs use nonzero dummy note commitments (inserting 0 is indistinguishable from the tree's empty leaf value).

11. **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.

12. **Enforce public input ranges.**

    * Require `publicAmountIn < 2^248` and `publicAmountOut < 2^248`. Values in `[2^248, p)` pass field canonicality checks but could overflow the balance equation inside the circuit (Section 7.1).
    * Require `publicRecipientAddress < 2^160`, `publicTokenAddress < 2^160`, and `depositorAddress < 2^160`. Values in `[2^160, p)` are canonical field elements but alias when interpreted as EVM addresses.
    * Require `validUntilSeconds < 2^32`. This keeps the public expiry timestamp within the protocol's 32-bit UNIX-seconds domain.

13. **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): 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.
    * 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 calls MUST use the following exact semantics:
    * `balanceOf(address(this))` MUST be executed via `staticcall`, MUST not revert, and MUST return exactly 32 bytes.
    * `transferFrom(msg.sender, address(this), publicAmountIn)` and `transfer(publicRecipientAddress, publicAmountOut)` MUST not revert and MUST satisfy one of:
      * returndata length is 0 and the target account has nonzero code length;
      * returndata length is exactly 32 bytes decoding to `true`.
    * Any other returndata shape, empty returndata from an account with zero code length, or a decoded `false` return value MUST be treated as failure.

    Fee-on-transfer and rebasing tokens are incompatible. The deposit-side balance-delta check rejects fee-on-transfer tokens; rebasing tokens are not reliably detectable. Tokens that charge fees only on outbound `transfer` (not on `transferFrom`) pass the deposit check but deliver less than `publicAmountOut` on withdrawal. Such tokens MUST NOT be deposited.

14. **Emit events.** Emit the following event:

    ```solidity
    event ShieldedPoolTransact(
        uint256 indexed nullifier0,
        uint256 indexed nullifier1,
        uint256 indexed transactionReplayId,
        uint256 noteCommitment0,
        uint256 noteCommitment1,
        uint256 noteCommitment2,
        uint256 leafIndex0,
        uint256 postInsertionCommitmentRoot,
        bytes outputNoteData0,
        bytes outputNoteData1,
        bytes outputNoteData2
    );
    ```

    `leafIndex0` is the note-commitment-tree leaf index of `noteCommitment0`; `noteCommitment1` is always at `leafIndex0 + 1`, and `noteCommitment2` is always at `leafIndex0 + 2`. `postInsertionCommitmentRoot` is the note-commitment root after all three note commitments have been inserted (distinct from `publicInputs.noteCommitmentRoot`, 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 `transactionReplayId` are indexed for efficient scanning and lookup. Note commitments, `postInsertionCommitmentRoot`, and all three `outputNoteData*` fields are non-indexed. Wallets discover incoming notes by scanning `ShieldedPoolTransact` events and interpreting the output note data per Section 15 and any additional supported delivery schemes.

    **Registration events:**

    ```solidity
    event UserRegistered(
        address indexed user,
        uint256 ownerNullifierKeyHash,
        uint256 noteSecretSeedHash
    );

    event NoteSecretSeedRotated(
        address indexed user,
        uint256 noteSecretSeedHash
    );

    event DeliveryKeySet(
        address indexed user,
        uint32 indexed schemeId,
        bytes keyBytes
    );

    event DeliveryKeyRemoved(
        address indexed user,
        uint32 indexed schemeId
    );

    ```

    Both `registerUser` overloads MUST emit `UserRegistered`. The 4-argument overload MUST also emit `DeliveryKeySet`. `rotateNoteSecretSeed` MUST emit `NoteSecretSeedRotated`. `setDeliveryKey` and `removeDeliveryKey` MUST emit the delivery-key events. Scanners use `UserRegistered` and `NoteSecretSeedRotated` to maintain local copies of the user registry tree, and MAY cache current delivery endpoints from the delivery-key events. Wallets and provers MAY also use the direct read methods in Section 5.3 as the canonical online read path.

### 6. Registries

#### 6.1 User Registry

The shielded pool MUST maintain a Poseidon Merkle tree mapping:

```
address → (ownerNullifierKeyHash, noteSecretSeedHash)
```

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 `ownerNullifierKeyHash` matches a registry Merkle proof — an unregistered address cannot receive notes. This opt-in registration model keeps ordinary Ethereum addresses as note owners through a registry-backed receive path, rather than requiring a separate privacy-native address format. Initial registration is a one-time operation per address via one of the `registerUser` overloads. Withdrawal recipients (`publicRecipientAddress`) do not need to be registered — withdrawals send to any Ethereum address. For the standardized on-chain address-only receive path, registered users SHOULD also set a delivery endpoint (Section 6.5).

Wallets and provers can read the current user-registry entry for a specific address via `getUserRegistryEntry`, the active delivery endpoint via `getDeliveryKey`, and the current accepted roots via `getCurrentRoots` (Section 5.3).

#### 6.2 Registration Methods

The contract MUST provide:

* `registerUser(ownerNullifierKeyHash, noteSecretSeedHash)` — callable by `msg.sender`. MUST revert if the address is already registered.
* `registerUser(ownerNullifierKeyHash, noteSecretSeedHash, schemeId, keyBytes)` — callable by `msg.sender`. MUST revert if the address is already registered. This overload also initializes the caller's registered delivery endpoint.
* `rotateNoteSecretSeed(newNoteSecretSeedHash)` — callable by `msg.sender`. MUST revert if the address is not registered.

All registration methods MUST respect the block-based root history invariant (Section 5.2.1). Registration methods MUST reject `ownerNullifierKeyHash >= p` or `noteSecretSeedHash >= p` to prevent field aliasing between on-chain storage and in-circuit Poseidon computation. `rotateNoteSecretSeed` MUST reject `newNoteSecretSeedHash >= p`. All registration methods MUST compute the resulting user-registry leaf and revert if it equals 0 — the zero leaf is reserved for the absent state.

The 4-argument `registerUser` overload MUST additionally require `schemeId != 0` and `keyBytes.length != 0`, write the caller's registered delivery endpoint atomically with the user-registry entry, and emit both `UserRegistered` and `DeliveryKeySet`. Users who do not want to use this registry use the 2-argument overload and MAY call `setDeliveryKey` later.

#### 6.3 Key Mutability

`ownerNullifierKeyHash` is immutable. `ownerNullifierKey` is therefore non-rotatable. If `ownerNullifierKey` is compromised, users can mitigate by rotating `noteSecretSeed` and auth methods.

`noteSecretSeedHash` is rotatable via `rotateNoteSecretSeed`. Rotating it does not affect ownership of existing notes, but changes the derived `noteSecret` used for future outputs after stale user roots expire. After rotation, users MUST retain the prior `noteSecretSeed` until the stale-root window (`USER_REGISTRY_ROOT_HISTORY_BLOCKS` blocks) expires and any transactions they authorized against the old root have either settled or been abandoned.

#### 6.4 Auth Policy Registry

The auth policy registry binds `(address, innerVkHash)` pairs to credentials. State layout is specified in Section 5.2. Registration is via `registerAuthPolicy` (direct only). See Section 5.3.

Wallets and provers can read the current status/version for a specific `(address, innerVkHash)` pair via `getAuthPolicy` and the current accepted roots via `getCurrentRoots` (Section 5.3).

**Rotation and revocation.** Auth policy rotation is bounded-delay, not instant, and operates per auth method. Other auth methods registered by the same address are unaffected. The old auth-policy root remains valid for up to `AUTH_POLICY_ROOT_HISTORY_BLOCKS` blocks. During this window, old intents (signed with the old `policyVersion`) remain provable against the stale root. If the user's own leaf changed (rotation or deregistration), the intent becomes permanently unprovable once the stale root expires.

Full revocation of a specific auth method becomes effective once the stale auth root ages out of the bounded history window. After that point:

* The old root is no longer accepted by the contract (Section 5.4, step 6).
* Old intents carry the old `policyVersion` in the signed intent → mismatch with the current registry leaf → outer proof failure.
* Re-registering identical credentials at a higher `policyVersion` does NOT resurrect old intents. The outer circuit uses the `policyVersion` authenticated through the old signed artifact, which mismatches the new leaf's incremented version.
* Rotating one auth method does not affect intents signed with other auth methods for the same address — each (address, innerVkHash) pair has its own `policyVersion`.

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

1. Publish an inner circuit that verifies the new signature scheme and outputs `[authDataCommitment, transactionIntentDigest]`, while authenticating `authorizingAddress` and `policyVersion` through the signed intent.
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). `innerVkHash` MUST be computed from the canonical inner-verification-key encoding defined in Section 9.1. Companion ERCs MUST authenticate all intent-digest fields. Companion ERCs MUST reject any authenticated value `>= p` before interpreting it as a BN254 field element, including `nonce` and any other authenticated input that feeds `transactionIntentDigest` or other field-based commitments. Companion ERCs MUST ensure an authorization valid for one `innerVkHash` is invalid for any other; this EIP does not define how. 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, ownerNullifierKeyHash)` — 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.

These inner-circuit extensions govern spend authorization only. `registerUser`, `rotateNoteSecretSeed`, `setDeliveryKey`, `removeDeliveryKey`, `registerAuthPolicy`, and `deregisterAuthPolicy` remain direct `msg.sender`-gated lifecycle methods. Users who want multisig or contract-governed lifecycle control SHOULD use a smart-contract wallet address as the registered note owner.

**Deactivation.** A user can deregister an auth method via `deregisterAuthPolicy` (Section 5.3), which writes the empty leaf (`0`) at the composite key. Deactivation is bounded-delay: the old auth-policy root remains valid for up to `AUTH_POLICY_ROOT_HISTORY_BLOCKS` blocks after deregistration. After expiry, no proof against that (address, innerVkHash) pair can succeed. A user may also replace credentials by re-registering with a new `authDataCommitment`, which increments `policyVersion` and invalidates old authorizations after stale roots expire. Global disabling of auth methods (e.g., pre-quantum schemes) requires a hard fork.

#### 6.5 Delivery Key Registry

The same system contract also maintains a public delivery-key registry mapping:

```
address → (schemeId, keyBytes)
```

Each address has at most one active registered delivery endpoint. `schemeId = 0` denotes no active registered endpoint in this registry. This registry is optional, is not Merkleized, is not referenced by any circuit, and does not affect proof validity or root histories.

The registry exists solely as a standardized on-chain discovery surface for the party constructing `outputNoteData` for a recipient. In first-party proving this is the wallet or user software; in delegated proving it is typically the prover. Delivery may also be coordinated out of band.

If `getDeliveryKey(recipient) == (0, "")`, this EIP provides no on-chain delivery metadata for that recipient. The transaction can still be constructed because `outputNoteData` is protocol-opaque, but note delivery must be coordinated out of band. Wallets SHOULD treat registry-based address-only private send as unavailable in that case.

Delivery-key registration is direct-only. Initial delivery-key setup MAY be combined with direct user registration via the 4-argument `registerUser` overload.

### 7. Note Commitment and Nullifiers

#### 7.1 Address and Amount Constraints

Inside the circuit:

* All address-valued fields (`ownerAddress`, `authorizingAddress`, `tokenAddress`, `depositorAddress`, `publicRecipientAddress`, `recipientAddress`, `feeRecipientAddress`) MUST be constrained to `< 2^160`. Without this, field aliasing could produce commitments or public inputs that pass proof verification but bind to different addresses than the EVM expects. The contract MUST also reject public `publicRecipientAddress`, `publicTokenAddress`, or `depositorAddress` values `>= 2^160` before interpreting them as EVM addresses.
* Amounts MUST be constrained to `< 2^248`. ERC-20 amounts are `uint256`, but the SNARK field is ~254 bits. The balance equation sums at most 4 terms per side; `4 * 2^248 < p` prevents field overflow. The contract MUST also reject `publicAmountIn` or `publicAmountOut` values `>= 2^248`.

#### 7.2 Note Commitment

Notes MUST commit to exactly the following fields:

```
noteCommitment = poseidon(
  amount,
  ownerAddress,
  noteSecret,
  ownerNullifierKeyHash,
  tokenAddress,
  originTag
)
```

* `ownerAddress` — 20-byte Ethereum address. The note owner: set to `recipientAddress` for transfer recipient notes and deposit notes, `authorizingAddress` for sender change notes (transfers and withdrawals), or the fee-note recipient (`feeRecipientAddress` when nonzero, otherwise prover-selected) for fee notes.
* `noteSecret` — the deterministically derived per-note secret used to blind the `noteCommitment` and make real note nullifiers note-specific. In this protocol it is derived from the sender's `noteSecretSeed`, the transaction's `transactionReplayId`, and the output's `outputIndex` (Section 9.5).
* `ownerNullifierKeyHash` — hash of the owner's nullifier key: `poseidon(OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey)`.
* `tokenAddress` — ERC-20 contract address, or `0` for ETH.
* `originTag` — optional origin-tracking tag (see Section 12).

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:

```
noteNullifier = poseidon(
  NOTE_NULLIFIER_DOMAIN,
  ownerNullifierKey,
  noteSecret
)
```

* `ownerNullifierKey` — a secret scalar known only to the note owner. Required to spend notes. Loss of this key means permanent loss of access to the associated shielded funds. Key derivation and storage are implementation-defined.
* `noteSecret` — the note's per-note secret.

Real note nullifiers are derived from `(ownerNullifierKey, noteSecret)`, not from Merkle position. Under valid V1 note creation, duplicate `(ownerNullifierKey, noteSecret)` pairs should not arise because `noteSecret` is deterministically derived from `(noteSecretSeed, transactionReplayId, outputIndex)` and `transactionReplayId` is one-time-use. `leafIndex_u32` remains part of the Merkle membership witness only.

#### 7.4 Phantom Nullifier

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

```
phantomNullifier = poseidon(
  PHANTOM_NULLIFIER_DOMAIN,
  ownerNullifierKey,
  transactionReplayId,
  inputIndex
)
```

* `inputIndex` is 0 or 1 (the unused input slot).
* `PHANTOM_NULLIFIER_DOMAIN` prevents collision with real nullifiers.
* `ownerNullifierKey` is the spender's secret — because it is private, an observer MUST NOT be able to distinguish phantom nullifiers from real ones.
* `transactionReplayId` (which incorporates `chainId`) provides per-transaction and per-chain uniqueness, preventing cross-chain phantom nullifier collisions.

The contract MUST treat phantom nullifiers indistinguishably from real nullifiers.

#### 7.5 Note Secret Seed

The sender-side note secret seed MUST hash to:

```
noteSecretSeedHash = poseidon(
  NOTE_SECRET_SEED_DOMAIN,
  noteSecretSeed
)
```

`noteSecretSeed` is used only for deterministic note-secret derivation. It does not affect note ownership or wallet-layer note delivery, and affects future real note nullifiers only through `noteSecret`. Unlike `ownerNullifierKey`, it is rotatable through the user registry (Section 6.3).

### 8. Operation Modes

The pool supports three operation modes, determined by public inputs:

#### 8.1 Deposit Mode

Deposit mode is selected when `depositorAddress != 0`.

Requirements:

* The depositor MUST be registered in the user registry (Section 6.1).
* The depositor MUST have a registered auth policy (Section 6.4).
* The recipient MUST be registered in the user registry — the circuit requires the recipient's `ownerNullifierKeyHash` for output note commitment binding.
* If `feeAmount != 0`, output slot 2's owner MUST be registered in the user registry.
* Inner proof REQUIRED (Section 9.1).
* `msg.sender == depositorAddress`.
* `publicTokenAddress` specifies the deposited asset (`0` for ETH, otherwise an ERC-20 address).
* `publicAmountIn > 0`.
* `publicAmountOut == 0`.
* `publicRecipientAddress == 0`.
* Both input slots MUST be phantom.
* `publicAmountIn == amount + feeAmount`, where `amount` is the signed intent amount and `feeAmount` is the optional private fee.
* Output slot 0 MUST be one real output owned by `recipientAddress` (from the transaction intent digest), with amount equal to the signed intent `amount` and `tokenAddress == publicTokenAddress`.
* Output slot 1 MUST be dummy.
* Output slot 2 MUST be a fee note with `amount == feeAmount`. If `feeRecipientAddress != 0`, its `ownerAddress` MUST equal `feeRecipientAddress`. If `feeAmount > 0` and `feeRecipientAddress == 0`, its `ownerAddress` MUST be prover-selected and nonzero. If `feeAmount == 0`, output slot 2 MUST be dummy.
* `validUntilSeconds > 0`.
* `operationKind` = `DEPOSIT_OP` (derived from `depositorAddress != 0`; no new operation kind).

Deposits expose token, amount, and depositor address on-chain; the note recipient is private.

#### 8.2 Transfer Mode (Shielded Transfer)

Transfer mode is selected when:

* `depositorAddress == 0`
* `publicAmountIn == 0`
* `publicAmountOut == 0`
* `publicRecipientAddress == 0`
* `publicTokenAddress == 0`

In transfer mode the token MUST be private (enforced inside the circuit); the on-chain transaction MUST NOT reveal token or amount. The transfer anonymity set spans all tokens because `publicTokenAddress` is zero.

Coin selection is delegated to the prover. The transaction intent digest binds payment semantics (recipient, amount, token, operation type), the selected origin-mode rule via `originMode`, and any signer-selected output-binding locks, but it does not bind exact note selection or exact output origin tags. Operation-type binding is the inner circuit's responsibility via `operationKind` in the transaction intent digest.

Output slot 0 is the recipient payment note, output slot 1 is sender change or dummy, and output slot 2 is the fee note or dummy.

#### 8.3 Withdrawal Mode (Public Withdrawal)

Withdrawal mode is selected when:

* `depositorAddress == 0`
* `publicAmountIn == 0`
* `publicAmountOut > 0`
* `publicRecipientAddress != 0`
* `publicTokenAddress` specifies the withdrawn token (`0` for ETH, otherwise ERC-20 address)

Withdrawals are public with respect to token, amount, and recipient address.

Output slot 0 is sender change or dummy, output slot 1 MUST be dummy, and output slot 2 is the fee note or dummy.

### 9. Circuit Requirements

This EIP specifies a recursive proof architecture. The **outer circuit** (hard-fork-managed) enforces protocol invariants. **Inner circuits** (permissionless) handle authentication and intent parsing. The outer circuit recursively verifies an inner circuit proof as part of its own verification.

Invariants (permanent, enforced by the outer circuit):

* Note commitment format (Section 7.2)
* Nullifier derivation from `ownerNullifierKey` — this is why cross-circuit spending works
* Value conservation constraints
* Note commitment tree structure and nullifier set
* User registry (Section 6): the outer circuit proves `ownerNullifierKeyHash` and the sender's `noteSecretSeedHash` against it
* Deterministic note-secret derivation

Independent extension axes:

* **Auth method:** permissionless via inner circuits (Section 4, Section 6.4)
* **Intent format:** inner-circuit-determined, specified by companion standards
* **Note-delivery scheme:** Section 15 defines scheme `1`; payload hashes are proof-bound, and finalized output slots MAY additionally be signer-constrained via output bindings

#### 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` uniquely identifies the inner circuit. The outer circuit computes it from the inner verification key provided as a private witness and uses it to look up the auth policy registry. Let `vk[0] .. vk[114]` denote the 115 BN254 field elements of the `UltraHonkVerificationKey` witness in recursive-verifier order. Compute leaves as `[poseidon(AUTH_VK_DOMAIN, 115), poseidon(vk[0], vk[1]), poseidon(vk[2], vk[3]), ..., poseidon(vk[112], vk[113]), vk[114]]`, then recursively fold that list as a binary tree with `poseidon(left, right)` to obtain `innerVkHash`.

**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 `[authDataCommitment, transactionIntentDigest]`.
2. Computes `outputBinding_i` from the actual commitments and `outputNoteDataHash_i` values (Section 9.7).
3. Enforces any locked-slot equalities against the actual `outputBinding_i` values (Section 9.12).
4. Computes `transactionIntentDigest` from private witnesses and public inputs (Section 9.11), including `authorizingAddress`, `policyVersion`, `recipientAddress`, `nonce`, and any signer-selected execution constraints. Enforces the result matches `transactionIntentDigest` from the inner proof output.
5. Computes `innerVkHash` from `innerVkey` (Inner VK Hash). Proves auth policy membership at key `uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash))`, where `authorizingAddress` is the same witness value used in `transactionIntentDigest`.
6. Computes `poseidon(AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion)` and verifies this equals the leaf opened at the composite key, where `policyVersion` is the same witness value used in `transactionIntentDigest`.
7. **Enforces `authorizingAddress == depositorAddress`** — the signer must be the depositor.
8. Binds `authorizingAddress` to the depositor's user registry entry (`ownerNullifierKeyHash`, `noteSecretSeedHash`).
9. Derives `transactionReplayId` per Section 9.8.
10. **Proves the recipient's user registry entry** using `recipientAddress` — obtains the recipient's `ownerNullifierKeyHash` for output note commitment binding.
11. **Constrains output slot 0's `ownerAddress` to `recipientAddress`**.
12. **Constrains output slot 2** to either a fee note (`amount == feeAmount`, owner determined per Section 9.5) or dummy.
13. Enforces `publicAmountIn == amount + feeAmount`.
14. 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`. Deposit mode additionally requires `authorizingAddress = depositorAddress`, `recipientAddress =` output slot 0 owner, `amount =` output slot 0 amount, and `tokenAddress = publicTokenAddress`. `feeRecipientAddress` and `feeAmount` govern output slot 2 per step 12.

**Transfer/withdrawal mode** (`depositorAddress == 0`):

The outer circuit:

1. Recursively verifies the inner proof against `innerVkey` with public outputs `[authDataCommitment, transactionIntentDigest]`.
2. Computes `outputBinding_i` from the actual commitments and `outputNoteDataHash_i` values (Section 9.7).
3. Enforces any locked-slot equalities against the actual `outputBinding_i` values (Section 9.12).
4. Computes `transactionIntentDigest` from execution data (Section 9.11), including `authorizingAddress`, `policyVersion`, `nonce`, and any signer-selected execution constraints. Enforces the result matches `transactionIntentDigest` from the inner proof output.
5. Computes `innerVkHash` from `innerVkey` (Inner VK Hash).
6. Computes auth-policy tree key `uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash))` and proves auth policy membership at that key, where `authorizingAddress` is the same witness value used in `transactionIntentDigest`.
7. Computes `poseidon(AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion)` and verifies this equals the leaf opened at the composite key, where `policyVersion` is the same witness value used in `transactionIntentDigest`.
8. Binds `authorizingAddress` to note ownership via user registry (`ownerNullifierKeyHash` and `noteSecretSeedHash`).
9. Derives `transactionReplayId` per Section 9.8.

**Inner Circuit Interface** (normative):

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

1. `authDataCommitment` — credential commitment proved against. Outer circuit checks it matches the auth-policy leaf.
2. `transactionIntentDigest` — digest over the signer-authenticated transaction fields and signer-selected execution constraints. Outer circuit checks it matches its own recomputation.

`authorizingAddress` and `policyVersion` are private outer-circuit witnesses reused in auth-policy verification.

`innerVkHash` is NOT an inner circuit output — the outer circuit computes it from the verification key used for recursive verification.

**Inner-proof envelope**: Inner circuits MUST conform to UltraHonk on BN254. The public output vector is exactly 2 field elements in this order: `authDataCommitment`, `transactionIntentDigest`. The outer circuit consumes the inner verification key as the 115-field-element `UltraHonkVerificationKey` witness described above and the inner proof as the corresponding `UltraHonkZKProof` witness accepted by the recursive verifier. No sample inner proof or sample inner verification key is normative. Auth methods requiring a different proof system need a hard fork.

**Security property:** The inner circuit MUST NOT have access to `ownerNullifierKey` or `noteSecretSeed`. Specifically, neither secret MUST appear as a witness or public input in the inner proof relation. The outer circuit derives `transactionReplayId` and `noteSecret` independently.

**Normative equality constraints** (MUST):

* `authorizingAddress` witness used in `transactionIntentDigest` == address used for auth policy lookup, user registry lookup, nullifier derivation, and change note ownership. `recipientAddress` from the transaction intent digest determines recipient note ownership (transfers) and deposit note ownership. If `feeRecipientAddress != 0`, it determines fee-note ownership in output slot 2; otherwise output slot 2 ownership is prover-selected at proof generation time. For deposits, `authorizingAddress` is additionally constrained to equal `depositorAddress`.
* `innerVkHash` computed from `innerVkey` == `innerVkHash` used in auth-policy tree key `uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash))`
* `authDataCommitment` from inner proof == `authDataCommitment` in auth policy leaf
* `policyVersion` witness used in `transactionIntentDigest` == `policyVersion` in auth policy leaf
* `transactionIntentDigest` from inner proof == outer circuit's recomputed transaction intent digest

#### 9.2 Note Ownership and Membership

For each input slot:

* If `isPhantom == 0` (real input): the circuit MUST prove Merkle membership in `noteCommitmentRoot`. The `noteCommitment` MUST include the signer's address, so only notes owned by the signer match.
* If `isPhantom == 1` (phantom input): membership MUST be skipped. The circuit MUST enforce `phantomNullifier = poseidon(PHANTOM_NULLIFIER_DOMAIN, ownerNullifierKey, transactionReplayId, inputIndex)` and `amount = 0`.

`isPhantom` MUST be constrained to 0 or 1.

In transfer and withdrawal modes (`depositorAddress == 0`), at least one input MUST be real (`isPhantom == 0`). (For withdrawals this is already implied by value conservation and `publicAmountOut > 0`; the constraint is stated explicitly for defense-in-depth.)

#### 9.3 Owner-Nullifier-Key and Note-Secret-Seed Binding

For real input slots, the circuit MUST enforce:

```
poseidon(
  OWNER_NULLIFIER_KEY_HASH_DOMAIN,
  ownerNullifierKey
) == note.ownerNullifierKeyHash
```

This binds the owner nullifier key to the key hash committed in the note.

For phantom input slots, the owner-nullifier-key binding MUST be skipped.

In deposit mode (both inputs phantom), the circuit MUST still enforce that `poseidon(OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey) == registryOwnerNullifierKeyHash(authorizingAddress)`, where `authorizingAddress` is the signer-authenticated witness value used in `transactionIntentDigest` (constrained to equal `depositorAddress` per Section 9.1) and `registryOwnerNullifierKeyHash` is the depositor's registered owner-nullifier-key hash proven via the user registry Merkle proof. This prevents an untrusted prover from choosing an arbitrary `ownerNullifierKey` for deposit outputs.

In all operation modes, the circuit MUST enforce:

```
poseidon(
  NOTE_SECRET_SEED_DOMAIN,
  noteSecretSeed
) == registryNoteSecretSeedHash(authorizingAddress)
```

where `registryNoteSecretSeedHash(authorizingAddress)` is extracted from the sender's user-registry leaf. This binds deterministic note-secret derivation 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):

* If `isDummy == 0` (real output): Real output notes MUST have `amount > 0`. The output note commitment MUST be correctly formed for its owner and token. `ownerNullifierKeyHash` MUST match the registry-proven key hash for that output's owner: recipient note, sender change note, or fee note. Additional per-mode constraints:
    * **Transfer**: Per Section 8.2. The circuit MUST enforce that output slot 0 is the recipient payment (note `ownerAddress` = `recipientAddress`, `ownerNullifierKeyHash` = recipient's registry-proven key hash, `amount` = authorized amount, `tokenAddress` = authorized token), output slot 1 is sender change or dummy (note `ownerAddress` = `authorizingAddress`, `ownerNullifierKeyHash` = sender's registry-proven key hash), and output slot 2 is a fee note or dummy (note `ownerAddress` = `feeRecipientAddress` if nonzero, otherwise prover-selected and nonzero; `ownerNullifierKeyHash` = that owner's registry-proven key hash; `amount` = `feeAmount`).
    * **Withdrawal**: Per Section 8.3. Output slot 0 is sender change or dummy, output slot 1 MUST be dummy, and output slot 2 is a fee note or dummy.
    * **Deposit**: Per Section 8.1. Output slot 0 is the recipient note, output slot 1 MUST be dummy, and output slot 2 is a fee note or dummy.
* If `isDummy == 1` (dummy output):
    * `amount` MUST equal 0.
    * `ownerAddress` MUST equal 0.
    * `tokenAddress` MUST equal 0.
    * `originTag` MUST equal 0.
    * `ownerNullifierKeyHash` MUST equal `DUMMY_OWNER_NULLIFIER_KEY_HASH`.
    * The `amount == 0` constraint prevents value extraction even if a preimage for `DUMMY_OWNER_NULLIFIER_KEY_HASH` were found.

For output slot 2 specifically, the circuit MUST enforce:

* `feeAmount == 0` iff output slot 2 is dummy, and then `feeRecipientAddress == 0`.
* `feeAmount > 0` iff output slot 2 is real.
* If `feeAmount > 0` and `feeRecipientAddress != 0`, then `ownerAddress == feeRecipientAddress`.
* If `feeAmount > 0` and `feeRecipientAddress == 0`, then `ownerAddress` MUST be nonzero. In that case the prover chooses output slot 2's owner at proof generation time.

The note secret MUST be deterministically derived for both real and dummy output slots:

```
noteSecret = poseidon(
  NOTE_SECRET_DOMAIN,
  noteSecretSeed,
  transactionReplayId,
  outputIndex
)
```

Dummy outputs use the same note-secret derivation as real outputs. This removes prover discretion over dummy note commitments. The resulting `noteCommitment` remains subject to the existing nonzero-commitment rule (Section 5.4, step 10).

Here `outputIndex` is the output-slot index `0`, `1`, or `2`.

For a fixed witness assignment (same input notes, same output ordering, same accepted `registryRoot`, same `noteSecretSeed`), note-secret derivation is deterministic. This removes prover discretion over note commitments given a fixed witness, but coin selection, output assignment, and registry root selection (within the valid history window) are not canonicalized.

#### 9.6 Registry Binding

Gated by operation type:

* **Transfer**: the outer circuit MUST prove the recipient address has a user registry entry so output slot 0 can bind the recipient's `ownerNullifierKeyHash`. The outer circuit MUST also prove the sender (`authorizingAddress` witness used in `transactionIntentDigest`) has a user registry entry, extracting both `ownerNullifierKeyHash` and `noteSecretSeedHash`. If `feeAmount != 0`, the outer circuit MUST additionally prove output slot 2's `ownerAddress` has a user registry entry. The outer circuit MUST prove auth policy membership for `authorizingAddress` (see Section 9.1).
* **Withdrawal**: the outer circuit MUST prove the sender has a user registry entry, extracting both `ownerNullifierKeyHash` and `noteSecretSeedHash`, so any change note binds the sender's note key and its `noteSecret` derives from the registered note-secret seed. If `feeAmount != 0`, the outer circuit MUST additionally prove output slot 2's `ownerAddress` has a user registry entry. The outer circuit MUST prove auth policy membership for `authorizingAddress`. Recipient binding is skipped — the recipient receives unshielded funds via `publicRecipientAddress`. Any address can be a withdrawal destination; compliance is handled by counterparty-level origin-proof protocols, not by registry membership.
* **Deposit**: the outer circuit MUST prove the depositor (`authorizingAddress`) has a user registry entry, extracting both the depositor's `ownerNullifierKeyHash` and `noteSecretSeedHash`. The circuit MUST additionally prove the recipient (`recipientAddress` from the transaction intent digest) has a user registry entry, extracting the recipient's `ownerNullifierKeyHash` for output slot 0 commitment binding. If `feeAmount != 0`, the circuit MUST additionally prove output slot 2's `ownerAddress` has a user registry entry. The outer circuit MUST prove auth policy membership for `authorizingAddress`.

#### 9.7 Output Note Data

`outputNoteDataHash0`, `outputNoteDataHash1`, and `outputNoteDataHash2` are public inputs that bind opaque note-delivery payloads to the proof. The 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.

For each slot `i`, the outer circuit MUST compute:

```
outputBinding_i = poseidon(
  OUTPUT_BINDING_DOMAIN,
  noteCommitment_i,
  outputNoteDataHash_i
)
```

Execution constraints (Section 9.12) MAY lock any subset of these three `outputBinding_i` values. If a slot is locked, the prover cannot change either the emitted note commitment or the emitted payload bytes for that slot after signing. If a slot is unlocked, the finalized output for that slot remains prover-discretionary subject to the rest of the proof relation.

The outer and inner circuits do not validate encryption scheme semantics or delivery format. Section 15 defines the registry lookup and the interpretation for scheme ID `1`.

#### 9.8 Transaction Replay ID

All operation modes use the same transaction replay ID derivation:

```
transactionReplayId = poseidon(
    TRANSACTION_REPLAY_ID_DOMAIN,
    ownerNullifierKey,
    authorizingAddress,
    executionChainId,
    nonce
)
```

* `ownerNullifierKey` — the owner's secret; makes the replay ID unguessable.
* `authorizingAddress` and `executionChainId` namespace replay across users and chains.
* `nonce` — the signer-chosen replay discriminator, authenticated by the inner circuit through `transactionIntentDigest`.

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 ensure nonce freshness for each new authorization.

#### 9.9 Origin Tag Propagation

The circuit MUST enforce output origin tags per Section 12.

The outer circuit MUST additionally enforce:

* If `originMode == ORIGIN_MODE_DEFAULT`, no extra origin constraint.
* If `originMode == ORIGIN_MODE_REQUIRE_TAGGED`:
    * **Deposit mode** (both inputs phantom): every real output origin tag MUST satisfy `originTag != 0`.
    * **Transfer/withdrawal mode**:
        * **One real input** (one phantom): the real input's `originTag` MUST satisfy `originTag != 0`.
        * **Two real inputs**: both real inputs' `originTag` values MUST be equal and nonzero.
        * Every real output origin tag, if any, MUST satisfy `originTag != 0`.
* Dummy outputs are exempt from the real-output check because they already enforce `originTag == 0`.
* If `originMode > ORIGIN_MODE_REQUIRE_TAGGED`, the proof MUST fail.

#### 9.10 Token Consistency

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

* For deposits and withdrawals: `tokenAddress == publicTokenAddress`. This binds the notes' private token to the public input that drives fund movement.
* For transfers: `publicTokenAddress == 0`. Token consistency is enforced privately within the circuit.

#### 9.11 Transaction Intent Digest

The transaction intent digest is the canonical digest of the contemplated pool action. Both inner and outer circuits compute the same formula independently: the inner circuit from the authenticated intent fields and any companion-standard constants, and the outer circuit from witnesses, public inputs, and mode-derived values. The inner circuit authenticates this digest; the outer circuit recomputes it and binds the authenticated transaction fields and signer-selected execution constraints to the proof.

```
transactionIntentDigest = poseidon(
    TRANSACTION_INTENT_DIGEST_DOMAIN,
    policyVersion,
    authorizingAddress,
    operationKind,
    tokenAddress,
    recipientAddress,
    amount,
    feeRecipientAddress,
    feeAmount,
    originMode,
    executionConstraintsFlags,
    lockedOutputBinding0,
    lockedOutputBinding1,
    lockedOutputBinding2,
    nonce,
    validUntilSeconds,
    executionChainId
)
```

* `feeRecipientAddress` MAY be zero. If `feeAmount > 0` and `feeRecipientAddress == 0`, output slot 2's `ownerAddress` is chosen by the prover at proof generation time. That address is not part of the transaction intent digest and is fixed only by the resulting proof.
* `originMode` binds whether the transaction may lose tagged-origin status. It does not bind exact note identities or an exact `originTag` value.

The outer circuit MUST derive `operationKind` from the public execution mode — it MUST NOT treat `operationKind` as an unconstrained witness. Derivation: `depositorAddress != 0` → `DEPOSIT_OP`; `depositorAddress == 0 AND publicAmountOut > 0` → `WITHDRAWAL_OP`; `depositorAddress == 0 AND publicAmountOut == 0` → `TRANSFER_OP`.

**Normative execution-field binding** (MUST):

* Withdrawal: `recipientAddress == publicRecipientAddress`, `amount == publicAmountOut`, `tokenAddress == publicTokenAddress`, `validUntilSeconds` == public input, `executionChainId == block.chainid` (checked by contract). `feeRecipientAddress`, `feeAmount`, and `originMode` are private. If `feeAmount > 0` and `feeRecipientAddress == 0`, output slot 2's `ownerAddress` is prover-selected and privately bound by the proof. `originMode` is enforced through Section 9.9.
* Transfer: `recipientAddress`, `amount`, `feeRecipientAddress`, `feeAmount`, and `originMode` are private (bound through intent-digest computation, output constraints, value conservation, and Section 9.9), `tokenAddress` is private (bound through token consistency, Section 9.10), `validUntilSeconds` == public input, `executionChainId == block.chainid` (checked by contract), `publicRecipientAddress == 0`, `publicAmountOut == 0`, `publicAmountIn == 0`, `publicTokenAddress == 0`. If `feeAmount > 0` and `feeRecipientAddress == 0`, output slot 2's `ownerAddress` is prover-selected and privately bound by the proof.
* Deposit: `authorizingAddress == depositorAddress`, `recipientAddress` = output slot 0 owner (from the signed intent), `amount` = output slot 0 amount, `tokenAddress == publicTokenAddress`, `publicAmountIn == amount + feeAmount`, `validUntilSeconds` == public input, `executionChainId == block.chainid` (checked by contract). `originMode` is private, bound through intent-digest computation, and determines whether deposit outputs derive a fresh nonzero origin tag or `0` per Section 12.1. If `feeAmount > 0` and `feeRecipientAddress == 0`, output slot 2's `ownerAddress` is prover-selected and privately bound by the proof.

#### 9.12 Execution Constraints

Execution constraints let the signer optionally bind finalized output slots without changing the nonce-based replay domain.

The private signed execution-constraints fields are:

* `executionConstraintsFlags`
* `lockedOutputBinding0`
* `lockedOutputBinding1`
* `lockedOutputBinding2`

Semantics:

* `executionConstraintsFlags` MUST be constrained to `< 2^32`.
* Any flag bit other than `LOCK_OUTPUT_BINDING_0`, `LOCK_OUTPUT_BINDING_1`, and `LOCK_OUTPUT_BINDING_2` MUST cause proof failure.
* If a lock bit is unset, the corresponding `lockedOutputBinding{i}` MUST equal `0`.
* If a lock bit is set, the corresponding `lockedOutputBinding{i}` MAY be any field element, including `0`.

These fields are authenticated directly as inputs to `transactionIntentDigest` in Section 9.11.

Then enforce per slot:

* If `executionConstraintsFlags & LOCK_OUTPUT_BINDING_0 != 0`, then `lockedOutputBinding0 == outputBinding_0`.
* If `executionConstraintsFlags & LOCK_OUTPUT_BINDING_0 == 0`, then `lockedOutputBinding0 == 0`.
* If `executionConstraintsFlags & LOCK_OUTPUT_BINDING_1 != 0`, then `lockedOutputBinding1 == outputBinding_1`.
* If `executionConstraintsFlags & LOCK_OUTPUT_BINDING_1 == 0`, then `lockedOutputBinding1 == 0`.
* If `executionConstraintsFlags & LOCK_OUTPUT_BINDING_2 != 0`, then `lockedOutputBinding2 == outputBinding_2`.
* If `executionConstraintsFlags & LOCK_OUTPUT_BINDING_2 == 0`, then `lockedOutputBinding2 == 0`.

This gives two valid proving modes:

* **Unconstrained mode**: no output-binding locks are set; finalized outputs remain prover-discretionary subject to the rest of the proof relation.
* **Finalized-authorization mode**: one or more output-binding locks are set; the signer fixes those slots' emitted note commitments and payload hashes before proving.

### 10. Public Inputs

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

* `noteCommitmentRoot` — note-commitment-tree root the proof is verified against.
* `nullifier0`, `nullifier1` — input note nullifiers. `nullifier1` is phantom when unused.
* `noteCommitment0`, `noteCommitment1`, `noteCommitment2` — output note commitments. `noteCommitment0` is the primary user-facing output note, `noteCommitment1` is sender change or dummy, and `noteCommitment2` is a fee note or dummy.
* `publicAmountIn` — tokens entering the shielded state (deposits); 0 otherwise.
* `publicAmountOut` — tokens leaving the shielded state (withdrawals); 0 otherwise.
* `publicRecipientAddress` — withdrawal destination address; 0 for deposits and transfers.
* `publicTokenAddress` — token being transacted (0 for ETH); 0 for transfers.
* `depositorAddress` — depositor's Ethereum address (deposits); 0 for transfers/withdrawals.
* `transactionReplayId` — replay protection.
* `registryRoot` — user registry root. MUST be nonzero.
* `validUntilSeconds` — intent expiry timestamp. MUST be > 0 and < `2^32` for all operation modes.
* `executionChainId` — verified by the contract against `block.chainid` (Section 5.4, step 2). Defense-in-depth against cross-chain proof replay.
* `authPolicyRegistryRoot` — auth policy registry root. MUST be nonzero for all operation modes.
* `outputNoteDataHash0` — `uint256(keccak256(outputNoteData0)) % p`. Binds the first output's opaque note-delivery payload to the proof.
* `outputNoteDataHash1` — `uint256(keccak256(outputNoteData1)) % p`. Binds the second output's opaque note-delivery payload to the proof.
* `outputNoteDataHash2` — `uint256(keccak256(outputNoteData2)) % p`. Binds the third output's opaque note-delivery payload to the proof.

`publicAmountIn` and `publicAmountOut` apply to the token specified by `publicTokenAddress`. For transfers, all three are zero.

`originMode`, `executionConstraintsFlags`, `lockedOutputBinding0`, `lockedOutputBinding1`, `lockedOutputBinding2`, `nonce`, and `transactionIntentDigest` are not public inputs. They are private signed/authenticated values checked inside the recursive proof relation.

#### 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 UltraHonk BN254 proofs for the fork-defined outer circuit. The canonical outer verification-key encoding is pinned by [`outer_vk.bin`](../assets/eip-8182/outer_vk.bin), [`outer_vk.sha256`](../assets/eip-8182/outer_vk.sha256), and [`outer_vk.bb_hash.hex`](../assets/eip-8182/outer_vk.bb_hash.hex). The canonical outer proof byte encoding and verifier-specific metadata required to interpret that proof are pinned by [`outer_verifier_metadata.json`](../assets/eip-8182/outer_verifier_metadata.json) together with [`outer_verifier_transcript_vk_hash.hex`](../assets/eip-8182/outer_verifier_transcript_vk_hash.hex).

* Address: `PROOF_VERIFY_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000030`
* Input: `abi.encode(bytes proof, PublicInputs publicInputs)` — the struct fields are ABI-encoded as 19 consecutive `uint256` values in declaration order. The pool-facing ABI supplies exactly those 19 public inputs. Any verifier-internal scalars carried inside the proof are not additional pool ABI inputs.
* Output: 32 bytes — `uint256(1)` on success, empty on failure.
* Gas: `1_000_000`. This is derived from the reference verifier benchmark, which measured `3,683,435` gas.
* Error: malformed input or verification failure returns empty.

### 12. Origin Tags

Every note MUST carry an `originTag` field. A note with `originTag != 0` is origin-tagged: the tag traces the note back to one originating deposit and can support later app-defined origin proofs. A note with `originTag == 0` is not origin-tagged. Origin tags are enforced by the circuit; they cannot be forged.

#### 12.1 Deposit Origin Tag

In deposit mode, real output origin tags MUST be determined by `originMode`:

* If `originMode == ORIGIN_MODE_DEFAULT`, every real output note MUST use `originTag = 0`.
* If `originMode == ORIGIN_MODE_REQUIRE_TAGGED`, every real output note MUST use:

```
originTag = poseidon(
  ORIGIN_TAG_DOMAIN,
  executionChainId,
  depositorAddress,
  tokenAddress,
  publicAmountIn,
  transactionReplayId
)
```

  If the derived `originTag` is `0`, the proof MUST fail.

`publicAmountIn` is the total public deposit amount, not individual output note amounts.

`executionChainId` (= `block.chainid`) prevents cross-chain origin-tag collisions. `transactionReplayId` is unique per transaction and known at proof generation time.

#### 12.2 Origin Tag Propagation

* **One real input** (one phantom): real output notes MUST inherit the real input's `originTag`.
* **Two real inputs, same `originTag`**: real output notes MUST inherit that `originTag`.
* **Two real inputs, different `originTag` values**: all real output notes MUST use `originTag = 0`. The simple origin-proof path is lost at the protocol level.
* **Both inputs phantom** (deposit mode): real output notes use the origin tags required by Section 12.1.

Dummy outputs are not covered by these propagation rules and remain `originTag = 0` per Section 9.5.

### 13. Poseidon Hash Contexts

| Context | Inputs (in order) | Arity |
|---------|-------------------|-------|
| Note commitment | `amount, ownerAddress, noteSecret, ownerNullifierKeyHash, tokenAddress, originTag` | 6 |
| Nullifier | `NOTE_NULLIFIER_DOMAIN, ownerNullifierKey, noteSecret` | 3 |
| Phantom nullifier | `PHANTOM_NULLIFIER_DOMAIN, ownerNullifierKey, transactionReplayId, inputIndex` | 4 |
| Owner nullifier key hash | `OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey` | 2 |
| Note secret seed hash | `NOTE_SECRET_SEED_DOMAIN, noteSecretSeed` | 2 |
| Note secret | `NOTE_SECRET_DOMAIN, noteSecretSeed, transactionReplayId, outputIndex` | 4 |
| Transaction replay ID (all modes) | `TRANSACTION_REPLAY_ID_DOMAIN, ownerNullifierKey, authorizingAddress, executionChainId, nonce` | 5 |
| Transaction intent digest | `TRANSACTION_INTENT_DIGEST_DOMAIN, policyVersion, authorizingAddress, operationKind, tokenAddress, recipientAddress, amount, feeRecipientAddress, feeAmount, originMode, executionConstraintsFlags, lockedOutputBinding0, lockedOutputBinding1, lockedOutputBinding2, nonce, validUntilSeconds, executionChainId` | 17 |
| Output binding | `OUTPUT_BINDING_DOMAIN, noteCommitment, outputNoteDataHash` | 3 |
| 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 | `poseidon(AUTH_VK_DOMAIN, 115)`; `poseidon(vk[0], vk[1])`, ..., `poseidon(vk[112], vk[113])`; final odd word `vk[114]`; then binary-tree fold with `poseidon(left, right)` | 115-word VK |
| Deposit origin tag | `ORIGIN_TAG_DOMAIN, executionChainId, depositorAddress, tokenAddress, publicAmountIn, transactionReplayId` | 6 |
| User registry leaf | `USER_REGISTRY_LEAF_DOMAIN, uint160(user), ownerNullifierKeyHash, noteSecretSeedHash` | 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 canonical inner-verification-key encoding and `innerVkHash` fixture are pinned in Section 9.1.

### 14. Example Single-Circuit ECDSA Companion Standard (Non-Normative)

This section sketches an example single-circuit inner circuit for ECDSA/secp256k1 authorization using [EIP-712](./eip-712.md) typed data signing.

The user signs an EIP-712 typed struct containing the example circuit's intent fields:

```
ShieldedPoolAuthorization(
    uint256 policyVersion,
    uint8   operationKind,
    address tokenAddress,
    address recipientAddress,
    uint256 amount,
    address feeRecipientAddress,
    uint256 feeAmount,
    uint8   originMode,
    uint256 nonce,
    uint32  validUntilSeconds
)
```

EIP-712 domain:

```text
{ name: "EIP-8182 Shielded Pool", version: "1", chainId: <executionChainId>, verifyingContract: <poolAddress> }
```

In this example companion standard, the domain binds `executionChainId` and `poolAddress` without repeating them in the struct. This is intentionally a single-circuit companion-standard example, not a general protocol-level mechanism for binding a signed authorization to the actual `innerVkHash` selected by the outer circuit. Companion standards that span multiple inner circuits still need to specify how they satisfy the cross-circuit invalidation requirement from Section 6.4. The actual `innerVkHash` used by the outer circuit is computed from `innerVkey` per Section 9.1.

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)`, where each coordinate is encoded as an exact 32-byte big-endian value, and derives `authorizingAddress` via `keccak256(ecdsaPubKeyX || ecdsaPubKeyY)[12:]`.
3. Reads the intent fields directly from the struct, takes `executionChainId` and `poolAddress` from the EIP-712 domain, and enforces `poolAddress == SHIELDED_POOL_ADDRESS`.
4. Enforces `nonce < p`.
5. Fixes the execution constraints to the unconstrained values `executionConstraintsFlags = 0`, `lockedOutputBinding0 = 0`, `lockedOutputBinding1 = 0`, and `lockedOutputBinding2 = 0`, then computes `transactionIntentDigest` per Section 9.11 using `executionChainId`, the derived `authorizingAddress`, the signed `policyVersion`, and those fixed values.
6. Outputs `[authDataCommitment, transactionIntentDigest]` where `authDataCommitment = poseidon(xHi, xLo, yHi, yLo)`, with `(xHi, xLo)` and `(yHi, yLo)` the first and last 16 bytes of those same 32-byte encodings interpreted as big-endian `uint128` values. This is the value registered in `registerAuthPolicy` for this example inner circuit.

### 15. Output Note Data and Delivery Keys

`outputNoteData0`, `outputNoteData1`, and `outputNoteData2` are opaque bytes emitted alongside the three output note commitments in `ShieldedPoolTransact`. The contract verifies only the hash binding (Section 9.7) and MUST NOT decode or validate payload contents. When a signer sets an output-binding lock for a slot, Section 9.12 binds that slot's payload hash to its emitted note commitment; otherwise payload choice remains prover-discretionary.

When using the delivery-key registry, the party constructing `outputNoteData` for a recipient looks up the recipient's active registered delivery endpoint via `getDeliveryKey(recipient)`. In first-party proving this is the wallet or user software; in delegated proving it is typically the prover. The sender/prover then constructs payload bytes according to the selected delivery scheme. Notes MAY also be delivered using out-of-band coordination.

The protocol does not require `outputNoteData` to carry a scheme tag or version. Recipients MAY therefore need to attempt recovery with their current delivery private key and any retained prior delivery keys and supported schemes.

Implementations SHOULD use constant-size real and dummy payloads within each supported scheme to reduce structural leakage.

#### 15.1 Scheme IDs and Support Requirements

The delivery-key registry uses the following scheme-ID namespace:

* `0` — unset / invalid
* `1` — X-Wing (`X25519` + `ML-KEM-768` hybrid KEM)
* all other nonzero values — reserved for future assignment or private use

Implementations claiming general interoperability with this EIP MUST support scheme `1`. They MAY support additional schemes.

The contract MUST accept any nonzero `schemeId` in `setDeliveryKey`; it does not maintain an on-chain allowlist and MUST NOT validate that `keyBytes` are well-formed for the selected scheme. A user can therefore publish malformed key material and break their own receive path.

#### 15.2 Scheme 1: X-Wing (`X25519` + `ML-KEM-768`)

For scheme `1`, `keyBytes` MUST be a raw 1216-byte X-Wing encapsulation key: the 1184-byte `ML-KEM-768` encapsulation key followed by the 32-byte `X25519` public key. This EIP pins the X-Wing KEM to `draft-connolly-cfrg-xwing-kem-10`, including key generation, encapsulation, decapsulation, and combiner behavior. Scheme `1` is frozen by that draft revision plus the normative vectors in [the scheme 1 vector asset](../assets/eip-8182/delivery_scheme1_vectors.json); later IETF changes do not alter EIP-8182 scheme `1`.

The plaintext is the six note fields in note-commitment order, each encoded as a 32-byte big-endian word:

```
amount || ownerAddress || noteSecret || ownerNullifierKeyHash || tokenAddress || originTag
```

Address fields are the corresponding `uint160` values left-padded to 32 bytes.

`outputNoteData` for scheme `1` MUST be exactly 1328 bytes and encoded as `enc || ciphertext || tag`, where:

* `enc` is the first 1120 bytes and is the raw X-Wing ciphertext: the 1088-byte `ML-KEM-768` encapsulation ciphertext followed by the 32-byte `X25519` ephemeral public key
* `ciphertext` is the next 192 bytes and is the AES-256-GCM ciphertext of the 192-byte plaintext above
* `tag` is the final 16 bytes and is the AES-256-GCM authentication tag

The sender/prover encapsulates to the registered X-Wing public key, obtaining `enc` plus the 32-byte X-Wing shared secret, and then derives the AEAD key and nonce from that shared secret with `HKDF-SHA256`:

```
prk = HKDF-Extract("", sharedSecret)
aeadKey = HKDF-Expand(prk, "EIP-8182-delivery-scheme-1 key", 32)
nonce = HKDF-Expand(prk, "EIP-8182-delivery-scheme-1 nonce", 12)
```

Empty associated data.

The recipient decapsulates `enc` with the corresponding X-Wing private key, derives the same AEAD key and nonce, verifies `tag`, decrypts `ciphertext`, recomputes the note commitment, and MUST reject on mismatch.

#### 15.3 Additional Schemes

Additional nonzero scheme IDs MAY be assigned by later standards. Such standards define the meaning of `keyBytes` and any payload interpretation for their assigned IDs.

When output slot 2 is used for fee compensation, the actual recipient of that note — whether designated by `feeRecipientAddress` or chosen by the prover when `feeRecipientAddress == 0` — SHOULD receive enough offchain fee-note data to recompute `noteCommitment2` 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](./eip-7503.md)) 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 note-secret derivation, 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 signed authorization 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.

The protocol 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. Rotating `noteSecretSeed` cuts off a former prover's ability to derive future note secrets for that address. Because note delivery is not coupled to the proof system, delivery schemes can evolve without changing the proof relation.

### Optional Origin Tracking

Not every private transfer should carry origin information by default. This EIP therefore keeps origin tracking opt-in: deposits create untagged notes unless the signer explicitly requests origin-tagged outputs. When a signer does request origin-tagged outputs, `originMode` lets them require tagged-origin handling without binding exact input-note selection or an exact `originTag` value.

Origin-tagged notes are compatible with later app-defined origin proofs, including association-set-style proofs defined outside this EIP. This EIP standardizes the origin primitive itself, not the external set-provider ecosystem or proof format.

### Finalized Output Binding

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

### Hybrid Delivery Baseline

No classical-only interoperable delivery scheme is defined. The baseline receive path uses the X-Wing `X25519` + `ML-KEM-768` hybrid KEM to avoid steering users toward note-delivery ciphertexts with known harvest-now-decrypt-later exposure while also hedging against failures in the post-quantum component. This choice is limited to note delivery; it does not make the overall protocol post-quantum.

### Private Fee Compensation

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

Private transfers need a way to compensate a broadcaster or sponsor without revealing the transferred token on-chain. A public fee output would leak the asset for shielded transfers, so this EIP reserves output slot 2 for an optional private fee note. If `feeRecipientAddress` is nonzero, the user designates the fee recipient in the signed intent. If `feeRecipientAddress` is zero and `feeAmount > 0`, the prover chooses output slot 2's owner at proof generation time, but that choice is still fixed by the resulting proof and cannot be changed at broadcast time. Keeping fee compensation inside the same note model also makes the design compatible with both legacy transactions and future transaction types that separate sender from gas payer: the transaction layer can decide who submits and who pays gas, while the pool continues to express compensation as an ordinary shielded note in the transferred asset.

### Transaction-Time Auth-Method Anonymity

`innerVkHash`, `authDataCommitment`, and `policyVersion` are private inputs, never exposed as public inputs in `transact`. See Section 4 for the full auth-method anonymity model.

### UTXO-Based Notes over Account-Based Encrypted Balances

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

## Backwards Compatibility

This EIP introduces new functionality via a system contract and precompiles and requires a network upgrade (hard fork). It does not change the meaning of existing transactions or contracts. No backward compatibility issues are known.

## Test Cases

Normative fixture assets for this EIP are included below. Implementations claiming conformance MUST match the pinned constants and vectors in at least the following asset families:

* Poseidon constants and vectors: [`poseidon_bn254_t3_rf8_rp57.json`](../assets/eip-8182/poseidon_bn254_t3_rf8_rp57.json) and [`poseidon_vectors.json`](../assets/eip-8182/poseidon_vectors.json)
* Shielded-pool activation state: [`shielded-pool-state.json`](../assets/eip-8182/shielded-pool-state.json)
* External Poseidon library runtime prerequisite: [`poseidon_t3_runtime.hex`](../assets/eip-8182/poseidon_t3_runtime.hex)
* Outer verifier pin set: [`outer_vk.bin`](../assets/eip-8182/outer_vk.bin), [`outer_vk.sha256`](../assets/eip-8182/outer_vk.sha256), [`outer_vk.bb_hash.hex`](../assets/eip-8182/outer_vk.bb_hash.hex), [`outer_verifier_transcript_vk_hash.hex`](../assets/eip-8182/outer_verifier_transcript_vk_hash.hex), and [`outer_verifier_metadata.json`](../assets/eip-8182/outer_verifier_metadata.json)
* Delivery scheme `1` vectors: [`delivery_scheme1_vectors.json`](../assets/eip-8182/delivery_scheme1_vectors.json)

Implementations MUST additionally test at least the following consensus-critical accept/reject families:

* malformed proof bytes or wrong proof-length rejection
* non-canonical field-element rejection
* address and amount range rejection
* root-history boundary acceptance and rejection
* auth-policy rejection when `innerVkHash` is computed incorrectly or does not match the auth-policy key proven in the outer circuit
* dummy-output constraint failures
* deposit outputs use `originTag = 0` when `originMode = ORIGIN_MODE_DEFAULT`
* deposit outputs use the derived nonzero origin tag when `originMode = ORIGIN_MODE_REQUIRE_TAGGED`
* deposit with `originMode = ORIGIN_MODE_REQUIRE_TAGGED` rejects if the derived origin tag is `0`
* one-input spends preserve the input `originTag`
* two-input spends preserve `originTag` when both real inputs carry the same value
* two-input spends clear `originTag` to `0` when real inputs carry different values
* tagged + untagged real inputs clear `originTag` to `0` when `originMode = ORIGIN_MODE_DEFAULT`
* full withdrawal with `originMode = ORIGIN_MODE_REQUIRE_TAGGED` accepts only when the real inputs share one nonzero `originTag`
* origin-tag preservation rejection when `originMode = ORIGIN_MODE_REQUIRE_TAGGED`
* reserved-flag-bit rejection
* locked-slot mismatch rejection

Implementations SHOULD additionally test tree-capacity failure at the depth-32 note-commitment-tree boundary.

Implementations SHOULD also test the finalized-output-binding and nonce-replay cases:

* changing only execution constraints changes `transactionIntentDigest` but not `transactionReplayId`
* reusing the same nonce across otherwise distinct authorizations yields the same `transactionReplayId`
* fresh nonce changes both `transactionIntentDigest` and `transactionReplayId` when all other fields remain the same
* a locked slot succeeds when `lockedOutputBinding{i} == poseidon(OUTPUT_BINDING_DOMAIN, noteCommitment_i, outputNoteDataHash_i)`
* a locked slot fails if `noteCommitment_i` changes while `outputNoteDataHash_i` stays fixed
* a locked slot fails if `outputNoteDataHash_i` changes while `noteCommitment_i` stays fixed
* an unlocked slot accepts `lockedOutputBinding{i} = 0` without requiring equality to `poseidon(OUTPUT_BINDING_DOMAIN, noteCommitment_i, outputNoteDataHash_i)`

## Security Considerations

### Multi-Auth Security Boundary

Every active `(address, innerVkHash)` pair is an independent spend-authorization path for the same notes (Section 6.4). Registering a weak inner circuit alongside a strong one widens the attack surface, but spending still also requires custody of `ownerNullifierKey`, the current `noteSecretSeed` accepted by the user-registry root, and the relevant proving material. 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 note-commitment root history is a fixed-size circular buffer (`NOTE_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 note-commitment buffer is the binding constraint — users must submit proofs before the buffer wraps past their proven root.

### Metadata Leakage

Deposits and withdrawals are public by design. Shielded transfer token and amount are private, but network-level metadata (timing, gas patterns, relayer behavior, transaction size) can still leak information. The constant 2-input/3-output shape with phantom/dummy slots mitigates some structural metadata leakage while reserving a fixed slot for optional fee compensation.

### State Growth

The pool accumulates append-only state for note commitments, nullifiers, and transaction replay IDs. This is the main state-growth cost of the design relative to overwrite-in-place balance models: these values cannot be safely pruned without breaking spend or replay protection. All pool state, including this append-only state, is ordinary Ethereum contract state and remains subject to Ethereum's general gas pricing and state-management trajectory.

### Output Note Data Leakage

Empty or variable-size dummy payloads can leak which outputs are real. See Section 15 for payload guidance.

### Auth Policy Registry Liveness

The block-based aging rule (at most one root history entry per block) prevents same-block churn from burning multiple history slots. An attacker making many `registerAuthPolicy` calls within a single block consumes at most one slot. However, an attacker can still churn across blocks by making a registration in every block over the window, filling the history with attacker-controlled roots. The buffer length bounds the cost of this attack — `AUTH_POLICY_ROOT_HISTORY_BLOCKS` blocks of sustained registrations. The buffer size is consensus-critical and stays fixed in this specification to prevent post-deployment changes that could shrink the revocation window.

This same history rule also creates a same-block liveness footgun for honest users: if a user updates a registry entry and later same-block mutations occur before `transact`, the intermediate root they proved against may no longer be preserved in history. Wallets and provers SHOULD prefer waiting at least one subsequent block after registry lifecycle changes unless they control ordering.

### Third-Party Prover Residual Visibility

A third-party prover permanently learns `ownerNullifierKey` and can monitor spends of previously known notes indefinitely. It also learns the current `noteSecretSeed`; rotating it via `rotateNoteSecretSeed` cuts off future note-secret derivation after stale user roots expire. `noteSecretSeed` compromise alone is recoverable through rotation. If `ownerNullifierKey` is compromised, users can mitigate by rotating `noteSecretSeed` and auth methods.

### Origin-Tagged Note Visibility

Origin tags are private from on-chain observers, but they are part of the note material available to note holders and to any prover that receives the note witness. A holder of an origin-tagged note, or a third-party prover helping spend it, may correlate that note to its originating deposit. This is intentional: origin-tagged notes exist to support later app-defined origin proofs. Users that do not need that capability SHOULD prefer untagged notes.

### Third-Party Prover Origin Discretion

Because coin selection is delegated, a third-party prover can intentionally choose inputs that clear origin tags and remove the simple origin-proof path without violating payment semantics. This discretion is partially constrainable: when the signer sets `originMode = ORIGIN_MODE_REQUIRE_TAGGED`, proofs fail unless transfer/withdrawal mode uses real inputs with one shared nonzero `originTag` and any real outputs remain origin-tagged.

### Third-Party Prover Delivery Sabotage

If the signer leaves output-binding locks unset, a remote prover with full witness can still emit unusable `outputNoteData` or otherwise mutate unlocked finalized outputs while producing a valid proof. This cannot steal funds or redirect payment, but it can make note recovery fail. If the signer sets an output-binding lock for a slot, the prover cannot change that slot's emitted note commitment or payload bytes after signing. This protects finalized-authorization flows only; it does not protect a blind signer from a malicious coordinator that assembled a bad finalized plan before signing. Additional higher-assurance mitigations include wallet-controlled finalization/broadcast, TEE-attested proving, MPC or witness-splitting systems that prevent any one prover from constructing an alternate proof, or future companion standards for richer authenticated execution binding. These mitigations are not standardized by this EIP.

## Copyright

Copyright and related rights waived via [CC0](../LICENSE.md).
