--- 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 a split-proof architecture. 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 private ETH and [ERC-20](./eip-20.md) transfers via a shielded-pool system contract. The pool does not mandate a single spend-authorization method: each user registers their own (e.g., ECDSA signature, passkey). Beyond installing the shielded-pool system contract at fork activation, this EIP introduces no new precompile, opcode, transaction type, or other change to the Ethereum protocol. ## Motivation Sending assets publicly on Ethereum is straightforward. A user chooses ETH or a token, specifies a recipient using an Ethereum address or ENS name, and clicks send in an Ethereum wallet. Recipients, wallets, and applications already know how to interpret that transfer because they rely on the same shared standards. Private transfers have no analogous shared default today, even though many ordinary financial activities require privacy. Payroll, treasury management, donations, and similar activities typically require that the sender, recipient, or amount not be globally visible. Without a shared private transfer layer, Ethereum cannot serve these use cases directly, so they are pushed toward traditional financial systems or other blockchains. If private transfers are valuable, why has the market not produced a widely adopted default on Ethereum? Because a private transfer application cannot compete on product quality alone. Its effectiveness also depends on how many users and how much value share the same pool. A small pool offers weak privacy even for a superior product, while a large pool can remain attractive even when competing products are better. That means app-layer teams cannot focus only on wallet UX, authentication, compliance, or proof systems. They must also persuade users to deposit into their pool, which is difficult when the pool is not already large. But growing the pool is only part of the problem. App-layer teams also have to decide how the pool changes over time. If the pool is upgradeable, the parties with the power to change it could compromise user funds. Immutable pools avoid that risk, but they cannot adapt as proof systems weaken or cryptographic assumptions change. Neither is a good foundation for common privacy infrastructure. The Ethereum protocol should break this impasse by providing a shared privacy layer. This EIP does that by defining a protocol-managed private transfer system, updated only through Ethereum's hard-fork process, that provides a common pool for ETH and compatible ERC-20 tokens. Notes themselves bind to hidden owner identifiers; wallets resolve recipients to those identifiers off-chain. Applications can then build on that base without each having to bootstrap, govern, and defend their own pool. ### Scope This EIP specifies the on-chain component: the pool contract, proof system, and auth-policy registry. End-to-end transaction privacy still requires complementary infrastructure (note delivery, mempool encryption, network-layer anonymity, wallet integration) that is out of scope. Note delivery in particular is left to wallet coordination or companion standards; see Section 12. ## Specification The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. ### 1. Overview This EIP defines: 1. A **system contract** deployed at a protocol-defined address, holding all shielded pool state (note-commitment tree, nullifier set, intent replay ID set, and auth-policy registry) with no proxy, no admin function, and no on-chain upgrade mechanism. 2. A **proof-free deposit path** that inserts one note for a hidden owner-side commitment. 3. A **split-proof architecture** for note spending: a fork-managed Groth16 BN254 pool proof verified by the system contract, plus an auth proof verified by a user-registered auth verifier contract via `staticcall`. 4. A **private auth-policy registry**: a single mutable Merkle tree of leaves binding each address to its `ownerNullifierKeyHash`, `noteSecretSeedHash`, and current policy set, without publishing the user-to-verifier mapping. These components are presented as a single EIP because they share state and form a single deployment unit. ### 2. Terminology * **Note**: A shielded UTXO-like object represented on-chain by a final `noteCommitment`. See Sections 7.4–7.5 for the semantic-body and final-commitment formulas. * **Nullifier**: The public spent-note marker for one real input note. * **Phantom nullifier**: The public spent-input marker for one phantom input slot. * **Pool circuit**: The hard-fork-managed circuit that enforces protocol invariants for note spending: value conservation, nullifiers, Merkle membership, deterministic note-secret derivation for ordinary outputs, blinded auth commitment recomputation, transaction-intent-digest recomputation, and auth policy checks. The system contract verifies its Groth16 proof against the embedded verification key. * **Auth circuit**: A permissionless circuit that handles authentication and intent parsing. Outputs `[blindedAuthCommitment, transactionIntentDigest]`. Each auth circuit has a corresponding `authVerifier` Solidity contract that verifies its proofs. See Sections 8.1 and 11. * **authVerifier**: The Solidity contract address that verifies auth proofs for one specific auth circuit. An address's current `policySetCommitment` may include multiple `policyCommitment`s, each registered for a different `authVerifier`. See Sections 6.1 and 11. * **Auth policy**: A `(authVerifier, authDataCommitment)` binding encoded in one `policyCommitment`, included in a user's `policySetCommitment` and reachable through the user's auth-policy registry leaf. * **authDataCommitment**: The opaque commitment a user binds inside `policyCommitment`. The auth circuit derives this value from the user's auth data. * **registrationBlinder**: Per-policy user secret hashed into `policyCommitment` to keep the `(user, authVerifier)` mapping unrecoverable from public tree state. Derivable from the wallet seed; stays witness-only. * **policyCommitment**: Opaque `uint256` Poseidon2 digest committing one auth method's `(authVerifier, authDataCommitment, registrationBlinder)`. A user's `policySetCommitment` is a Merkle root over their currently active `policyCommitment` values. * **policySetCommitment**: A depth-`POLICY_SET_DEPTH` sparse Merkle root over the user's currently active `policyCommitment` values. Empty slots are `0`. A user revokes all policies by overwriting `policySetCommitment` with the empty-set root; no spend can satisfy policy-set membership against an empty-set root because the spend's `policyCommitment` is constrained to be nonzero. * **leafPosition**: Each user's assigned slot in the auth-policy registry. Assigned sequentially by the contract on first registration. Used as the tree key for proving leaf membership. * **blindedAuthCommitment**: `poseidon(BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor)`. Public auth-proof output and pool-proof input; per-tx blinding hides the registered auth data. * **blindingFactor**: Fresh per-tx random value used as preimage input to `blindedAuthCommitment`. Bound by the auth proof's authorization relation but excluded from `transactionIntentDigest`. * **Transaction intent digest**: The canonical digest of the contemplated private-note spend. It includes the transaction fields bound by the auth relation, the chosen `authVerifier`, and a random `nonce`. The auth circuit authenticates this digest from the authorization-bound intent fields and any companion-standard constants; the pool circuit recomputes the same formula from witnesses, public inputs, and mode-derived values. * **Intent replay ID**: The transaction-level replay identifier consumed on use. It shares the replay domain inputs across all outputs from one `transact` call. See Section 8.7. * **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. * **Owner nullifier key**: The note owner's non-rotatable hidden note-ownership key. Hashed into `ownerNullifierKeyHash` (Section 7.2). * **leafIndex**: The final note-tree leaf index assigned by the contract when the note is inserted. * **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. Delivery may be coordinated out of band or standardized by a companion ERC; see Section 12. * **Output binding**: `poseidon(OUTPUT_BINDING_DOMAIN, noteBodyCommitment, outputNoteDataHash)`. This binds one emitted semantic note commitment to one output-note-data hash for authorization-bound finalized-output locking. * **Execution constraints**: The private authorization-bound fields `executionConstraintsFlags` and `lockedOutputBinding0/1/2`. They optionally bind finalized output slots. * **authorizingAddress**: The Ethereum address whose registered auth policy is being used to authorize a `transact` call. Bound into `transactionIntentDigest`. * **recipientOwnerNullifierKeyHash**: The `ownerNullifierKeyHash` of the private recipient of a transfer. Authorization-bound in `transactionIntentDigest`. MUST be `0` in withdrawal-mode authorizations. * **feeNoteRecipientOwnerNullifierKeyHash**: The `ownerNullifierKeyHash` of the fee-note recipient when `feeAmount > 0`. Authorization-bound. MUST be `0` when `feeAmount == 0`. * **feeAmount**: A private witness in the transaction intent digest. The optional private fee paid through output slot 2. `0` means no fee. * **nonce**: A private authorization-bound random `uint256` value used for replay protection and transaction-intent-digest privacy in `transact`. * **executionConstraintsFlags**: A private authorization-bound bitmask selecting which finalized-output slots are locked. * **lockedOutputBinding0/1/2**: Private authorization-bound `uint256` values that optionally lock `outputBinding0/1/2` for slots 0, 1, and 2. * **publicRecipientAddress**: The Ethereum address receiving public assets in a withdrawal. Authorization-bound in `transactionIntentDigest` and surfaced as a public input. MUST be `0` in transfer-mode authorizations. * **publicTokenAddress**: Public input. The withdrawn token address. Zero for private transfers. * **publicAmountOut**: Public input. The withdrawn amount. Zero for private transfers. ### 3. Parameters and Constants #### 3.1 Domain Separators All Poseidon hashes that require domain separation MUST include a distinct domain tag (field element). Each domain tag is derived as: ``` DOMAIN = uint256(keccak256("eip-8182.")) mod p ``` where `p` is the BN254 scalar field order (the field over which the pool SNARK circuit and Poseidon operate) and `` is the string identifier listed below. This derivation is deterministic and fixes all domain tags. The following domain tags are defined by this EIP: | Constant | Context string | Usage | |----------|---------------|-------| | `OWNER_NULLIFIER_KEY_HASH_DOMAIN` | `owner_nullifier_key_hash` | Owner nullifier key hashing | | `OWNER_COMMITMENT_DOMAIN` | `owner_commitment` | Owner-side note commitment | | `NOTE_BODY_COMMITMENT_DOMAIN` | `note_body_commitment` | Semantic note commitment | | `NOTE_COMMITMENT_DOMAIN` | `note_commitment` | Final inserted note commitment | | `NULLIFIER_DOMAIN` | `nullifier` | Real note nullifiers | | `PHANTOM_NULLIFIER_DOMAIN` | `phantom_nullifier` | Phantom nullifiers | | `INTENT_REPLAY_ID_DOMAIN` | `intent_replay_id` | Intent replay IDs | | `TRANSACT_NOTE_SECRET_DOMAIN` | `transact_note_secret` | Ordinary output note-secret derivation | | `NOTE_SECRET_SEED_DOMAIN` | `note_secret_seed` | Note secret seed hashing | | `TRANSACTION_INTENT_DIGEST_DOMAIN` | `transaction_intent_digest` | Transaction intent digests | | `OUTPUT_BINDING_DOMAIN` | `output_binding` | Per-slot output bindings | | `AUTH_POLICY_DOMAIN` | `auth_policy` | Auth-policy registry tree leaves | | `POLICY_COMMITMENT_DOMAIN` | `policy_commitment` | Wallet-submitted auth-policy commitment | | `BLINDED_AUTH_COMMITMENT_DOMAIN` | `blinded_auth_commitment` | Blinded auth commitments | Internal Merkle-tree nodes use `poseidon(left, right)`; the Section 3.3 length-tagged sponge separates these 2-input hashes from domain-tagged application hashes. #### 3.2 Fixed Constants * `MAX_INTENT_LIFETIME = 86400` — maximum allowed forward offset from `block.timestamp` to `validUntilSeconds`, in seconds (24 hours), checked at submission time. This means proofs are accepted only during the final 24 hours before expiry; it does not measure authorization age from signing time. Root-history windows independently bound proof freshness. * `NOTE_COMMITMENT_ROOT_HISTORY_SIZE = 500` — consensus-critical, fixed by spec. * `AUTH_POLICY_ROOT_HISTORY_BLOCKS = 64` — block-based window for the auth-policy registry root history. Consensus-critical, fixed by spec. * `POLICY_SET_DEPTH = 8` — depth of the per-leaf policy-set Merkle tree. * `DUMMY_OWNER_NULLIFIER_KEY_HASH = poseidon(OWNER_NULLIFIER_KEY_HASH_DOMAIN, 0xdead)` — used for dummy output slots. The circuit enforces `amount == 0` for dummy outputs, preventing value extraction regardless of preimage knowledge. * `TRANSFER_OP = 0` — operation kind for private transfers. * `WITHDRAWAL_OP = 1` — operation kind for withdrawals. * `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 Poseidon2 over the BN254 scalar field `p` (defined in Section 3.1) with the following parameters: * State width: `t = 4` (capacity = 1, rate = 3) * S-box: `x^5` (`α = 5`) * Full rounds: `R_F = 8` * Partial rounds: `R_P = 56` * External matrix, internal diagonal, and round constants: exactly the constants in [the Poseidon2 parameter asset](../assets/eip-8182/poseidon2_bn254_t4_rf8_rp56.json). The corresponding normative vectors are in [the Poseidon2 vector asset](../assets/eip-8182/poseidon2_vectors.json). The single hash function used throughout this EIP is: ``` poseidon(x_1, ..., x_N) = Poseidon2_sponge(x_1, ..., x_N) ``` `Poseidon2_sponge` is defined as follows. Initialize the 4-element state to `[0, 0, 0, N << 64]`, where `N` is the number of inputs. If `N = 0`, apply one Poseidon2 permutation to this initial state and return state element 0. Otherwise, partition the inputs into `⌈N/3⌉` chunks of 3 elements each, zero-padding the final chunk with `0` when `N mod 3 ≠ 0`. For each chunk `[c_0, c_1, c_2]` in order, compute `state[j] ← (state[j] + c_j) mod p` for `j ∈ {0, 1, 2}`, then apply one Poseidon2 permutation to the state. After all chunks are processed, return state element 0. Because the capacity position encodes `N << 64`, `poseidon(a, b)` is not equivalent to the bare-permutation form that initializes capacity to `0` (as used by some Poseidon2 Merkle tree libraries). Implementations MUST use the length-tagged sponge form defined here to match this EIP's hash outputs and tree roots. #### 3.4 Merkle Tree Constructions Unless otherwise stated, all Merkle trees in this EIP hash internal nodes as `poseidon(left, right)` per Section 3.3. The length-tagged sponge initializes a 2-input node hash to a distinct sponge state from any domain-separated application hash with arity `≥ 3`, so the two cannot collide. Empty internal nodes follow the ladder `EMPTY[i + 1] = poseidon(EMPTY[i], EMPTY[i])` with `EMPTY[0] = 0` (named per tree, e.g. `EMPTY_NOTE_COMMITMENT`). **Note commitment tree.** Depth-32 append-only. Leaf indices are `uint32` values in `[0, 2^32 - 1]`, assigned sequentially from 0. Empty leaf is `0`. A membership proof is an ordered list of 32 sibling nodes from leaf level upward. At height `h` in `[0, 31]`, bit `h` of `leafIndex` (least-significant bit at height 0) selects left (`0`) or right (`1`) child when computing `poseidon(left, right)`. **Auth-policy registry tree.** Depth-32 sparse mutable Poseidon Merkle tree, keyed by `leafPosition` (LSB-first). Each registered Ethereum address has exactly one assigned `leafPosition` (Section 6.1). Leaf value: `poseidon(AUTH_POLICY_DOMAIN, uint160(user), ownerNullifierKeyHash, noteSecretSeedHash, policySetCommitment)`. Empty leaf is `0`. The contract maintains a block-based root history with window `AUTH_POLICY_ROOT_HISTORY_BLOCKS` (Section 5.2.1); the pre-update root is recorded on every `setAuthPolicy` call. **Policy-set tree.** Depth-`POLICY_SET_DEPTH` sparse Poseidon Merkle tree of `policyCommitment` values, keyed LSB-first like the other trees in this EIP, computed off-chain by the wallet to produce `policySetCommitment` (Section 6.1). Not maintained on-chain. Empty slots are `0`. #### 3.5 Public-Input Field-Element Encoding Each public input is a `uint256` interpreted as a BN254 scalar field element and MUST satisfy `x < p` (Section 3.1). This is automatic for Poseidon2 outputs, addresses (`< 2^160`), bounded amounts (`< 2^248`), and `uint32` fields. `outputNoteDataHash0/1/2` are explicitly reduced `mod p` per Section 8.6. The system contract rejects any non-canonical public input; otherwise `x` and `x + p` would verify identically but map to different `uint256` storage keys, enabling nullifier reuse or intent replay. ### 4. Architecture This EIP uses a split-proof architecture that splits note spending into two independently-verified proofs with different trust properties. **Deposits are contract-native.** Public deposits create notes directly through the pool contract. No proof is required for deposit insertion. The split-proof architecture below applies to `transact`, which spends existing private notes. **Pool proof** (Groth16 BN254 SNARK, hard-fork-managed). There is exactly one pool circuit; its relation can only change via hard fork. It enforces all protocol invariants for `transact`: value conservation, nullifier derivation, Merkle membership, deterministic note-secret derivation for ordinary outputs, auth-policy registry leaf and policy-set membership checks, sender identity binding, blinded-auth-commitment recomputation, transaction-intent-digest recomputation, and token consistency. The system contract verifies this proof using the embedded verification key (Section 5.4.1 step 7). The pool circuit is the security boundary — a bug here can compromise all funds in the pool. **Auth proof** (permissionless). Anyone can write and deploy an auth circuit and a corresponding `authVerifier` Solidity contract. It handles authentication — verifying the user's credential or policy — and intent parsing — computing the transaction intent digest over transaction fields, the chosen `authVerifier`, and any authorization-selected execution constraints. It outputs two public values: `[blindedAuthCommitment, transactionIntentDigest]`. The system contract dispatches the auth proof to the user-selected `authVerifier` via `staticcall` (Section 11). Both proofs are verified in one `transact` call (pool within the system contract, auth via `staticcall` to `authVerifier`); both share `[blindedAuthCommitment, transactionIntentDigest]` taken from the pool's public inputs. Section 8.1 is the normative interface. | Responsibility | Where enforced | Fork required? | |----------------|----------------|----------------| | Value conservation, nullifier derivation, Merkle membership | Pool | Yes | | Deterministic ordinary note-secret derivation | Pool | Yes | | Auth-policy registry leaf membership | Pool | Yes | | Policy-set membership | Pool | Yes | | Sender identity binding | Pool | Yes | | Intent replay ID, transaction-intent-digest, blinded-auth-commitment recomputation | Pool | Yes | | Pool proof verification and auth verifier dispatch | System contract | Yes | | Credential or policy authorization, intent parsing | Auth | No | | Auth data commitment derivation, blinded auth commitment construction | Auth | No | A bug in the pool circuit risks every note; a bug in an auth circuit risks only identities with a policy registered for that `authVerifier` in any accepted `authPolicyRoot`. ### 5. System Contract #### 5.1 Deployment and Upgrade Model The shielded pool is deployed as a system contract at `SHIELDED_POOL_ADDRESS = 0x0000000000000000000000000000000000081820`. At the activation fork, clients MUST install a system-contract account at `SHIELDED_POOL_ADDRESS` implementing this specification. The exact bytecode is incorporated into client releases at activation time. * The code at `SHIELDED_POOL_ADDRESS` can only be replaced by a subsequent hard fork that sets new code as part of its state transition rules. * There is no proxy, no admin function, and no on-chain upgrade mechanism. * Storage persists across fork-initiated code replacements. The verification-key byte layout, public-input layout, and pairing equation are normative in Section 5.5. The system contract embeds the verification key in its bytecode and verifies pool proofs against that embedded key. The verification key is fixed by a one-time multi-party trusted-setup ceremony for the pool circuit, the same pattern used for KZG in [EIP-4844](./eip-4844.md); the bytecode is finalized when that ceremony completes, which is why this EIP does not pin a specific bytecode. #### 5.2 State The pool MUST maintain: * **Note commitment tree** — append-only Poseidon Merkle tree (depth: 32, ~4B leaves). Empty leaf = 0. Holds multi-asset notes (`tokenAddress` is inside the note commitment). The contract MUST revert if `nextLeafIndex + 3 > 2^32` before a `transact` insertion or if `nextLeafIndex + 1 > 2^32` before a `deposit` insertion. * **Note commitment root history** — circular buffer (size: `NOTE_COMMITMENT_ROOT_HISTORY_SIZE`, consensus-critical). On each `transact` and each `deposit`, the contract MUST push the pre-insertion note-commitment root into this buffer. The contract accepts the current root or any historical root still in the buffer. * **Nullifier set** — `mapping(uint256 => bool)`. * **Intent replay ID set** — `mapping(uint256 => bool)`. * **Auth-policy registry tree** — depth-32 sparse mutable Poseidon Merkle tree, keyed by `leafPosition`. Empty leaf = 0. Each registered address has exactly one assigned slot. * **Auth-policy root history** — block-based root history with window `AUTH_POLICY_ROOT_HISTORY_BLOCKS` (Section 5.2.1). On every `setAuthPolicy`, the contract MUST record the pre-update auth-policy-registry root per the rules in Section 5.2.1. * **`userEntries`** — `mapping(address => UserEntry)` holding each registered address's per-user record (Section 5.3): assigned `leafPosition`, locked `ownerNullifierKeyHash`, current `noteSecretSeedHash`, and current `policySetCommitment`. An entry's `leafPosition == 0` denotes an unassigned address. * **`ownerNullifierKeyHashIndex`** — `mapping(uint256 => address)` enforcing global one-address-per-`ownerNullifierKeyHash`. `address(0)` denotes an unregistered `ownerNullifierKeyHash`. * **`nextLeafPosition`** — `uint256` counter, initial value `1`, constrained `< 2^32` before each increment. Slot `0` is reserved as the "unassigned" sentinel so no real user is ever assigned slot `0`. #### 5.2.1 Auth-Policy Root History The auth-policy registry tree's root history is block-based. For window `W = AUTH_POLICY_ROOT_HISTORY_BLOCKS`, the contract maintains a ring buffer of `W + 1` `(root, blockNumber)` pairs. The extra slot prevents a mutation in block `N + W` from overwriting a root that is still within the acceptance window. On the first mutation to the auth-policy tree in block `N`, the contract MUST snapshot the root accepted at the start of block `N` into the ring buffer at position `N mod (W + 1)` with `blockNumber = N`. Subsequent mutations in block `N` update the current root but MUST NOT create additional history entries. A candidate root `r` is accepted iff there exists a stored pair `(storedRoot, storedBlockNumber)` such that `storedRoot == r` and `block.number - storedBlockNumber <= W`. The current root is always accepted. `r = 0` is never accepted, regardless of history contents. Because only the start-of-block root is preserved, intermediate same-block roots are not retained once later same-block mutations occur. Wallets and provers SHOULD avoid depending on same-block auth-policy state changes unless transaction ordering is controlled; the safer default is to wait at least one subsequent block before proving against the new root. #### 5.3 Contract Interface The pool MUST expose the following functions. **Private-note spend path** ```solidity struct PublicInputs { uint256 noteCommitmentRoot; uint256 nullifier0; uint256 nullifier1; uint256 noteBodyCommitment0; uint256 noteBodyCommitment1; uint256 noteBodyCommitment2; uint256 publicAmountOut; uint256 publicRecipientAddress; uint256 publicTokenAddress; uint256 intentReplayId; uint256 validUntilSeconds; uint256 executionChainId; uint256 authPolicyRoot; uint256 outputNoteDataHash0; uint256 outputNoteDataHash1; uint256 outputNoteDataHash2; uint256 authVerifier; uint256 blindedAuthCommitment; uint256 transactionIntentDigest; } function transact( bytes calldata poolProof, bytes calldata authProof, PublicInputs calldata publicInputs, bytes calldata outputNoteData0, bytes calldata outputNoteData1, bytes calldata outputNoteData2 ) external ``` **Public deposit path** ```solidity function deposit( address token, uint256 amount, uint256 ownerCommitment, bytes calldata outputNoteData ) external payable ``` **Read methods** ```solidity function getCurrentRoots() external view returns ( uint256 noteCommitmentRoot, uint256 authPolicyRoot ) function isAcceptedNoteCommitmentRoot( uint256 root ) external view returns (bool) function isAcceptedAuthPolicyRoot( uint256 root ) external view returns (bool) function isNullifierSpent( uint256 nullifier ) external view returns (bool) function isIntentReplayIdUsed( uint256 intentReplayId ) external view returns (bool) struct UserEntry { uint32 leafPosition; uint256 ownerNullifierKeyHash; uint256 noteSecretSeedHash; uint256 policySetCommitment; } function getAuthPolicyEntry( address user ) external view returns ( bool registered, UserEntry memory entry ) ``` `getCurrentRoots` returns the current note-commitment root and the current auth-policy-registry root accepted by the contract. `isAcceptedNoteCommitmentRoot` and `isAcceptedAuthPolicyRoot` return whether the supplied root would currently pass the same acceptance rule enforced by `transact`. `isAcceptedAuthPolicyRoot(0)` MUST return `false`. `isNullifierSpent` returns whether the supplied nullifier has already been marked spent. `isIntentReplayIdUsed` returns whether the supplied intent replay ID has already been consumed. `getAuthPolicyEntry` returns the registered state for `user`. `registered` is `true` iff `userEntries[user].leafPosition != 0`; for an unregistered address `entry` MUST be the zero-valued `UserEntry`. **Auth-policy registration** ```solidity function setAuthPolicy( uint256 ownerNullifierKeyHash, uint256 noteSecretSeedHash, uint256 policySetCommitment ) external returns (uint256 leafPosition) ``` `setAuthPolicy` is called by `msg.sender` to register or update their auth-policy registry leaf. The caller computes `policySetCommitment` off-chain as the depth-`POLICY_SET_DEPTH` Merkle root over their currently active `policyCommitment` values, where each `policyCommitment = poseidon(POLICY_COMMITMENT_DOMAIN, uint160(authVerifier), authDataCommitment, registrationBlinder)`. `setAuthPolicy` does not expose individual `policyCommitment` values, the `authVerifier`s, `authDataCommitment`s, or `registrationBlinder`s. (`authVerifier` is revealed at spend time as a public input to `transact`; see §5.4.1 step 8 and Metadata Leakage.) * MUST reject any of `ownerNullifierKeyHash`, `noteSecretSeedHash`, or `policySetCommitment` `>= p` (Section 3.5). * MUST reject `ownerNullifierKeyHash == 0` or `ownerNullifierKeyHash == DUMMY_OWNER_NULLIFIER_KEY_HASH`. Reserving the dummy value at registration makes dummy notes structurally unspendable (the spend circuit would otherwise be willing to bind to the dummy key whose preimage `0xdead` is well-known). * MUST reject `noteSecretSeedHash == 0`. * Let `entry = userEntries[msg.sender]`. * If `entry.leafPosition == 0` (first call from this address): * MUST require `ownerNullifierKeyHashIndex[ownerNullifierKeyHash] == address(0)`. * MUST require `nextLeafPosition < 2^32`. * Sets `leafPosition = nextLeafPosition`, then increments `nextLeafPosition`. * Sets `entry.leafPosition = uint32(leafPosition)`. * Sets `entry.ownerNullifierKeyHash = ownerNullifierKeyHash`. * Sets `entry.noteSecretSeedHash = noteSecretSeedHash`. * Sets `entry.policySetCommitment = policySetCommitment`. * Sets `ownerNullifierKeyHashIndex[ownerNullifierKeyHash] = msg.sender`. * Otherwise (subsequent call): * Reads `leafPosition = entry.leafPosition`. * MUST require `entry.ownerNullifierKeyHash == ownerNullifierKeyHash`. `ownerNullifierKeyHash` is permanent. * Updates `entry.noteSecretSeedHash = noteSecretSeedHash`. The seed is rotatable. * Updates `entry.policySetCommitment = policySetCommitment`. The policy set is rotatable. * Computes `leafValue = poseidon(AUTH_POLICY_DOMAIN, uint160(msg.sender), ownerNullifierKeyHash, noteSecretSeedHash, policySetCommitment)`. The on-chain hash binds the leaf's `user` field to `msg.sender` by construction; a caller cannot forge a leaf claiming another address. * MUST revert if `leafValue == 0`. * Records the pre-update auth-policy-registry root in the root history per Section 5.2.1. * Writes `leafValue` at `leafPosition` in the auth-policy registry tree. * Emits `AuthPolicySet`. * Returns `leafPosition`. Same-value calls (submitting the values already registered) are permitted. The contract still records the pre-update root and emits `AuthPolicySet`; the leaf write is a no-op. To revoke all currently active policies, a caller submits `policySetCommitment` equal to the depth-`POLICY_SET_DEPTH` empty-set root. The contract performs no special-casing of this value; the in-circuit spend-side constraints make the resulting leaf state unspendable. Deactivation is delayed by the auth-policy root-history window (Section 6.1). The contract MUST emit: ```solidity event ShieldedPoolTransact( uint256 indexed nullifier0, uint256 indexed nullifier1, uint256 indexed intentReplayId, address authVerifier, uint256 noteCommitment0, uint256 noteCommitment1, uint256 noteCommitment2, uint256 leafIndex0, uint256 postInsertionCommitmentRoot, bytes outputNoteData0, bytes outputNoteData1, bytes outputNoteData2 ); event ShieldedPoolDeposit( address indexed depositor, uint256 noteCommitment, uint256 leafIndex, uint256 amount, uint256 tokenAddress, uint256 postInsertionCommitmentRoot, bytes outputNoteData ); event AuthPolicySet( address indexed user, uint256 ownerNullifierKeyHash, uint256 noteSecretSeedHash, uint256 policySetCommitment, uint256 leafPosition, uint256 leafValue, uint256 postUpdateAuthPolicyRoot ); ``` #### 5.4 Execution `transact` and `deposit` MUST each be non-reentrant. ##### 5.4.1 `transact` On each `transact` call, the pool MUST execute the following steps: 1. **Verify execution chain ID.** Require `executionChainId == block.chainid`. 2. **Enforce intent expiry.** * 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. 3. **Check note-commitment root.** Require `noteCommitmentRoot` equals the current note-commitment root or is in the note-commitment root history. 4. **Check auth-policy root.** Require `authPolicyRoot` equals the current auth-policy-registry root or is in the auth-policy root history (Section 5.2.1). `authPolicyRoot` MUST be nonzero. 5. **Enforce nullifier uniqueness.** Require `nullifier0 != nullifier1`. The contract MUST NOT attempt to distinguish phantom nullifiers from real ones. 6. **Enforce public input ranges.** * Require `publicAmountOut < 2^248`. Larger values could overflow the balance equation inside the circuit (Section 7.1). * Require `publicRecipientAddress < 2^160`, `publicTokenAddress < 2^160`, and `authVerifier < 2^160`. Values `>= 2^160` alias when interpreted as EVM addresses. * Require `validUntilSeconds < 2^32`. * Require `executionChainId < 2^32`. * Require `authVerifier != 0`. 7. **Verify the pool proof.** Verify `poolProof` against `publicInputs` using the embedded Groth16 BN254 verification key per Section 5.5. Revert if any failure mode in Section 5.5 is hit. 8. **Verify the auth proof via the auth verifier.** Construct `authPublicInputs = abi.encode(blindedAuthCommitment, transactionIntentDigest)`. Invoke `IAuthVerifier(address(uint160(authVerifier))).verifyAuth(authPublicInputs, authProof)` via `staticcall` (Section 11). MUST revert if the staticcall reverts, returns non-32 bytes, or returns `false`. 9. **Mark nullifiers spent.** Require both nullifiers are unspent; then mark them spent. 10. **Mark intent replay ID used.** Require `intentReplayId` is unused; then mark it used. 11. **Verify output note data hashes.** For each `i ∈ {0, 1, 2}`, require `(uint256(keccak256(outputNoteData_i)) mod p) == outputNoteDataHash_i` (Section 8.6), binding the payloads to the proof. The contract MUST NOT otherwise interpret or validate payload contents. 12. **Execute public asset movement.** `transact` is non-payable; any `msg.value > 0` reverts on entry. Exactly one of the following two branches MUST match: * **Withdrawal** (`publicAmountOut > 0`) * Require `publicRecipientAddress != 0`. * If `publicTokenAddress == 0` (ETH): perform a low-level `CALL` to `address(uint160(publicRecipientAddress))` with value `publicAmountOut`, empty calldata, and all remaining gas; require success. * If `publicTokenAddress != 0` (ERC-20): execute `transfer(publicRecipientAddress, publicAmountOut)` and require success. * **Transfer** (`publicAmountOut == 0`) * Require `publicRecipientAddress == 0`. * Require `publicTokenAddress == 0`. 13. **Assign leaf indices and insert outputs; emit event.** * Require `nextLeafIndex + 3 <= 2^32`. * Let `leafIndex0 = nextLeafIndex`. * Compute: ``` noteCommitment0 = poseidon(NOTE_COMMITMENT_DOMAIN, noteBodyCommitment0, leafIndex0) noteCommitment1 = poseidon(NOTE_COMMITMENT_DOMAIN, noteBodyCommitment1, leafIndex0 + 1) noteCommitment2 = poseidon(NOTE_COMMITMENT_DOMAIN, noteBodyCommitment2, leafIndex0 + 2) ``` * Require all three final commitments are nonzero. Dummy outputs use nonzero dummy note commitments; inserting 0 is indistinguishable from the tree's empty leaf value. * Push the pre-insertion root to note-root history. * Insert the three final commitments in order. * Emit `ShieldedPoolTransact`. The pool proof is a fixed 256-byte Groth16 BN254 string encoding the canonical proof elements `(A, B, C)`. Pool-proof verification MUST reject any malformed encoding. ERC-20 calls in both `transact` and `deposit` MUST use the following exact semantics: * `balanceOf(address(this))` MUST be executed via `staticcall`, MUST not revert, and MUST return exactly 32 bytes. * `transferFrom(msg.sender, address(this), amount)` and `transfer(recipient, amount)` MUST not revert and MUST satisfy one of: * returndata length is 0 and the target account has nonzero code length; * returndata length is exactly 32 bytes decoding to `true`. * Any other returndata shape, empty returndata from an account with zero code length, or a decoded `false` return value MUST be treated as failure. Fee-on-transfer and rebasing tokens are incompatible. The deposit-side balance-delta check rejects fee-on-transfer tokens; rebasing tokens are not reliably detectable. Tokens that charge fees only on outbound `transfer` (not on `transferFrom`) pass the deposit check but deliver less than the requested amount on withdrawal. Such tokens MUST NOT be deposited. ##### 5.4.2 `deposit` On each `deposit` call, the pool MUST execute the following steps: 1. **Range checks.** * Require `amount > 0`. * Require `amount < 2^248`. * Require `ownerCommitment != 0`. * Require `ownerCommitment < p` (Section 3.5). 2. **Receive public assets.** * If `token == address(0)` (ETH): require `msg.value == amount`. * If `token != address(0)` (ERC-20): require `msg.value == 0`. Record `balBefore = balanceOf(address(this))`. Execute `transferFrom(msg.sender, address(this), amount)` and require success. Require `balanceOf(address(this)) - balBefore == amount`. 3. **Assign leaf index.** Require `nextLeafIndex + 1 <= 2^32`. Let `leafIndex = nextLeafIndex`. 4. **Compute commitments.** ``` noteBodyCommitment = poseidon( NOTE_BODY_COMMITMENT_DOMAIN, ownerCommitment, amount, uint160(token) ) noteCommitment = poseidon( NOTE_COMMITMENT_DOMAIN, noteBodyCommitment, leafIndex ) ``` Require `noteCommitment != 0`. 5. **Insert the note.** * Push the pre-insertion root to note-root history. * Insert the final note commitment. 6. **Emit `ShieldedPoolDeposit`.** The contract does not validate or decode `outputNoteData`. It does not prove or enforce on-chain that `ownerCommitment` corresponds to a registered address. The standard receive flow is: 1. sender resolves the recipient's `ownerNullifierKeyHash` and any wallet-layer or companion-standard delivery information via off-chain discovery, 2. sender chooses or derives `noteSecret`, 3. sender computes `ownerCommitment = poseidon(OWNER_COMMITMENT_DOMAIN, ownerNullifierKeyHash, noteSecret)`, 4. sender calls `deposit`. #### 5.5 Pool Proof Verification The system contract embeds the canonical Groth16 BN254 verification key and verifies pool proofs against that embedded key using the standard Groth16 verification equation. Replacing the verification key requires a hard fork. * **Public input vector**: the 19 fields of `PublicInputs` (Section 5.3), in declaration order. Each is a BN254 scalar field element per Section 3.5. * **Proof encoding**: a fixed 256-byte string encoding the canonical Groth16 BN254 proof elements `(A, B, C)`. Any other encoding is malformed. * **Failure modes**: the system contract MUST revert the calling `transact` on any of: malformed proof encoding, any public input `>= p` (Section 3.5), or pairing-equation failure. * **Verification key**: in the standard Groth16 BN254 layout (`α ∈ G1`; `β, γ, δ ∈ G2`; `IC[0..19] ∈ G1`), embedded in the system contract bytecode at fork-activation time. ### 6. Auth Policy Registry #### 6.1 Structure and Lifecycle The auth-policy registry is a single depth-32 sparse mutable Poseidon Merkle tree, keyed by `leafPosition`. Each registered Ethereum address has exactly one assigned `leafPosition`. The leaf at position `p` is: ``` poseidon( AUTH_POLICY_DOMAIN, uint160(user), ownerNullifierKeyHash, noteSecretSeedHash, policySetCommitment ) ``` **Root history.** Block-based with window `AUTH_POLICY_ROOT_HISTORY_BLOCKS` (Section 5.2.1). Any update to a leaf records the pre-update root in history; spends may prove against the current root or any root still within the window. **Identity binding.** The first call to `setAuthPolicy` from an address assigns its `leafPosition`, locks `ownerNullifierKeyHash`, and registers the global uniqueness index entry. The triple `(address, leafPosition, ownerNullifierKeyHash)` is permanent for the lifetime of the identity. To rotate `ownerNullifierKeyHash`, a user MUST register a new identity from a fresh address and migrate notes by issuing transfers from the old identity to the new — the protocol provides no shortcut. **Mutable fields.** `noteSecretSeedHash` and `policySetCommitment` may both change on subsequent `setAuthPolicy` calls. Rotation of either takes effect only after the pre-rotation auth-policy root ages out of the root-history window: until then, spends against historical roots remain valid using the prior leaf state. Wallets MUST retain the prior `noteSecretSeed` after rotation until the window expires and any in-flight transactions have settled or been abandoned. **Policy-set commitment.** `policySetCommitment` is a depth-`POLICY_SET_DEPTH` sparse Merkle root over the user's currently active `policyCommitment` values, computed off-chain. Each `policyCommitment = poseidon(POLICY_COMMITMENT_DOMAIN, uint160(authVerifier), authDataCommitment, registrationBlinder)` is hidden inside `policySetCommitment`; registration does not expose `authVerifier`, `authDataCommitment`, or `registrationBlinder`. (`authVerifier` is revealed at spend time as a public input to `transact`.) Adding an auth method, removing one, or revoking all are all the same operation: compute the new `policySetCommitment` and call `setAuthPolicy`. To revoke all policies, submit the depth-`POLICY_SET_DEPTH` empty-set root. The pool circuit (Section 8) enforces that the spend's `policyCommitment` is nonzero and has a valid Merkle path in `policySetCommitment`; against an empty-set root, no nonzero leaf has a valid path, so no spend can succeed. Wallets pick slot positions within the policy-set tree; the protocol does not canonicalize slot assignment. Duplicate `policyCommitment` entries are permitted but serve no purpose. To revoke a policy, the new `policySetCommitment` MUST exclude every slot containing that policy's `policyCommitment`; leaving a duplicate behind leaves the policy effective. **Cross-method note compatibility.** Note commitments bind to `ownerNullifierKeyHash`, not to any specific `policyCommitment`. A note created when one method was used is spendable through any other method currently in the address's `policySetCommitment`. **Adding a new auth method.** Publish the auth circuit and its `authVerifier` Solidity contract per Section 11. Wallets compute the new `policySetCommitment` over the address's existing `policyCommitment` values plus the new one and call `setAuthPolicy`. No hard fork. **Wallet-side state.** A wallet retains, per active policy, enough metadata to reproduce that policy's `policyCommitment` and its position in the policy-set tree. Losing this metadata for a specific policy makes that policy unusable for spending; the user can include or replace it in a future `setAuthPolicy` call. Note ownership is unaffected because notes bind to `ownerNullifierKeyHash`. After rotating `noteSecretSeed`, wallets MUST also retain the prior seed until the root-history window expires. **Deactivation semantics.** A `setAuthPolicy` call that removes a policy or rotates the seed leaves the prior leaf valid through accepted historical roots for up to `AUTH_POLICY_ROOT_HISTORY_BLOCKS` blocks. Spends mounted against an older root within the window continue to use the prior policy set and `noteSecretSeed`. After the window, only the current leaf state is reachable. Wallets SHOULD treat any rotation or revocation as taking full effect only after the window expires. Users planning a post-quantum migration or other security-motivated rotation MUST rotate sufficiently in advance of the threat materializing. **Lifecycle gating.** `setAuthPolicy` is `msg.sender`-gated. Users who want multisig or contract-governed lifecycle control SHOULD use a smart-contract wallet address. ### 7. Note Commitment and Nullifiers #### 7.1 Address and Amount Constraints Inside the pool circuit for `transact`: * all address-valued witnesses (`authorizingAddress`, `tokenAddress`, `publicRecipientAddress`) MUST be constrained to `< 2^160`. Without this, field aliasing could produce commitments or public inputs that pass proof verification but bind to different addresses than the EVM expects. * amounts MUST be constrained to `< 2^248`. * `leafPosition` MUST be constrained to `< 2^32`, matching the depth-32 auth-policy registry tree. Contract-side, the pool MUST reject: * `publicRecipientAddress`, `publicTokenAddress`, or `authVerifier` values `>= 2^160` before interpreting them as EVM addresses in `transact`. * `publicAmountOut >= 2^248` in `transact` and deposit `amount >= 2^248` in `deposit`. #### 7.2 ownerNullifierKeyHash `ownerNullifierKeyHash` MUST be computed as: ``` ownerNullifierKeyHash = poseidon( OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey ) ``` `ownerNullifierKeyHash` is the hidden ownership identifier bound into notes. #### 7.3 ownerCommitment The owner-side note commitment MUST be computed as: ``` ownerCommitment = poseidon( OWNER_COMMITMENT_DOMAIN, ownerNullifierKeyHash, noteSecret ) ``` `ownerCommitment` hides both `ownerNullifierKeyHash` and `noteSecret` from on-chain observers. On `deposit`, the contract treats `ownerCommitment` as an uninterpreted `uint256` — it does not derive `ownerNullifierKeyHash` or `noteSecret` from it and does not verify its construction. On `transact`, it is a private witness reconstructed inside the pool circuit from the spender's `ownerNullifierKeyHash` and the note's `noteSecret`. #### 7.4 Note Body Commitment The semantic note commitment MUST be computed as: ``` noteBodyCommitment = poseidon( NOTE_BODY_COMMITMENT_DOMAIN, ownerCommitment, amount, tokenAddress ) ``` This binds the note's owner-side fragment, amount, and token. #### 7.5 Final Note Commitment The final inserted note commitment MUST be computed as: ``` noteCommitment = poseidon( NOTE_COMMITMENT_DOMAIN, noteBodyCommitment, leafIndex ) ``` `leafIndex` is the sequential note-tree leaf index assigned by the contract at insertion time. This is the structural uniqueness source for notes. #### 7.6 Nullifier A real input note nullifier MUST be computed as: ``` nullifier = poseidon( NULLIFIER_DOMAIN, noteCommitment, ownerNullifierKey ) ``` This formula is mode-agnostic: it applies to notes created by `deposit` and to notes created by `transact`. #### 7.7 Phantom Nullifier If an input slot is phantom, the circuit MUST use: ``` phantomNullifier = poseidon( PHANTOM_NULLIFIER_DOMAIN, ownerNullifierKey, intentReplayId, inputIndex ) ``` * `inputIndex` is 0 or 1. * `PHANTOM_NULLIFIER_DOMAIN` prevents collision with real note nullifiers. * `intentReplayId` provides per-transaction uniqueness. The contract MUST treat phantom nullifiers indistinguishably from real nullifiers. #### 7.8 Note Secret Seed The note-secret seed MUST hash to: ``` noteSecretSeedHash = poseidon( NOTE_SECRET_SEED_DOMAIN, noteSecretSeed ) ``` `noteSecretSeed` is the source of deterministic randomness for the user's `transact`-output note secrets (Section 7.9); deposit `noteSecret` is wallet-chosen. The seed is rotatable through `setAuthPolicy` (Section 6.1); rotation takes full effect after the pre-rotation auth-policy root ages out of the root-history window. During the window, spends against historical roots continue to derive output secrets from the prior seed, so wallets MUST retain the prior seed until the window expires. Rotation does not affect ownership of existing notes (those bind to `ownerNullifierKeyHash`, not to the seed). #### 7.9 Note Secret `noteSecret` is the per-note hidden blinder. Wallets MUST NOT reuse `noteSecret` across notes they create, because reuse creates linkability. Nullifier safety does not depend on `noteSecret` uniqueness in this design because structural note uniqueness comes from `leafIndex`. For ordinary `transact` outputs, the circuit MUST derive: ``` noteSecret = poseidon( TRANSACT_NOTE_SECRET_DOMAIN, noteSecretSeed, intentReplayId, outputIndex ) ``` Here `outputIndex` is `0`, `1`, or `2`. For deposits, the depositor chooses `noteSecret` using any recoverable wallet-side rule or randomness and conveys it to the recipient through `outputNoteData` or out-of-band coordination. The contract does not validate `noteSecret` or its derivation. Standardized wallet-side derivations MAY be defined by companion ERCs. ### 8. Pool Circuit Requirements #### 8.1 Pool Circuit Interface and Auth Proof Coupling The pool circuit MUST: 1. open the auth-policy registry leaf at the witnessed `leafPosition` against `authPolicyRoot`, where the opened leaf equals `poseidon(AUTH_POLICY_DOMAIN, uint160(authorizingAddress), ownerNullifierKeyHash, noteSecretSeedHash, policySetCommitment)`. The opened leaf is the sole source for `ownerNullifierKeyHash`, `noteSecretSeedHash`, and `policySetCommitment` used downstream, 2. enforce the range constraint `leafPosition < 2^32` (Section 7.1), 3. recompute `policyCommitment = poseidon(POLICY_COMMITMENT_DOMAIN, uint160(authVerifier), authDataCommitment, registrationBlinder)` and enforce `policyCommitment != 0`, 4. prove `policyCommitment` is a member of `policySetCommitment` via a depth-`POLICY_SET_DEPTH` Merkle path per Section 3.4, 5. recompute `blindedAuthCommitment = poseidon(BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor)` and enforce equality with public input `blindedAuthCommitment`, 6. recompute `transactionIntentDigest` per Section 8.9 and enforce equality with public input `transactionIntentDigest`, 7. derive `intentReplayId` per Section 8.7 and enforce that the derived value equals public input `intentReplayId`, 8. validate input note ownership and nullifiers, 9. validate output note-body commitments and output bindings, 10. enforce value conservation and token consistency. `authVerifier`, `blindedAuthCommitment`, and `transactionIntentDigest` are public inputs (Section 9). `ownerNullifierKeyHash`, `noteSecretSeedHash`, `policySetCommitment`, `policyCommitment`, `authDataCommitment`, `blindingFactor`, `registrationBlinder`, `leafPosition`, and the Merkle paths are private witnesses. All constraints MUST be expressed over the BN254 scalar field per Section 3.5. **Auth proof relation.** Each auth circuit and its corresponding `authVerifier` Solidity contract (Section 11) MUST prove knowledge of the auth data committed by `authDataCommitment`, the canonical `authDataCommitment` derivation from that auth data, and satisfaction of a verifier-defined authorization relation that binds every `transactionIntentDigest` input (Section 8.9) plus `blindingFactor`, such that: 1. the intent's `authVerifier` field equals the Solidity address of the verifier contract handling the `verifyAuth` call. Companion standards define how a verifier binds its own address into the auth proof relation. 2. public output 0 equals `poseidon(BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor)`; 3. public output 1 equals the Section 8.9 formula (which excludes `blindingFactor`); 4. neither `ownerNullifierKey` nor `noteSecretSeed` appears in the auth proof relation. Auth-proof public inputs are exactly `[blindedAuthCommitment, transactionIntentDigest]`, in that order. The system contract passes those two values from the pool proof's public inputs into the auth verifier (Section 5.4.1 step 8). This is the cross-proof coupling; neither proof verifies the other directly. Nonce and blinding-factor freshness are wallet obligations (Security Considerations). #### 8.2 Input Ownership and Membership For each input slot: * If `isPhantom == 0` (real input): * the circuit MUST prove Merkle membership in `noteCommitmentRoot`, * the circuit MUST recompute `ownerNullifierKeyHash = poseidon(OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey)`, * the circuit MUST recompute `ownerCommitment`, `noteBodyCommitment`, `noteCommitment`, and `nullifier`, * the circuit MUST enforce that the recomputed `noteCommitment` equals the committed leaf being opened. * If `isPhantom == 1` (phantom input): * membership MUST be skipped, * the circuit MUST enforce `phantomNullifier = poseidon(PHANTOM_NULLIFIER_DOMAIN, ownerNullifierKey, intentReplayId, inputIndex)`, * `amount = 0`. `isPhantom` MUST be constrained to 0 or 1. At least one input MUST be real. The recomputed input nullifier for slot `i` MUST equal public input `nullifier_i` for `i ∈ {0, 1}`. This applies whether the slot is real (nullifier derived per Section 7.6) or phantom (nullifier derived per the phantom-nullifier rule above). #### 8.3 Sender `ownerNullifierKeyHash` and Note-Secret-Seed Binding In all spend modes, the circuit MUST enforce: ``` poseidon(OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey) == ownerNullifierKeyHash ``` where `ownerNullifierKeyHash` is the value extracted from the opened auth-policy registry leaf (Section 8.1). `ownerNullifierKey` is a single pool-circuit witness reused across all real input slots, this recomputation, and phantom-nullifier derivation. The circuit MUST NOT instantiate per-slot `ownerNullifierKey` witnesses. The circuit MUST also enforce: ``` poseidon(NOTE_SECRET_SEED_DOMAIN, noteSecretSeed) == noteSecretSeedHash ``` where `noteSecretSeedHash` is similarly extracted from the opened leaf. This binds ordinary output note-secret derivation to the sender's currently registered seed. #### 8.4 Value Conservation The circuit MUST enforce: ``` sum(input_amounts) == sum(output_amounts) + publicAmountOut ``` Both sides MUST include range checks to prevent overflow. #### 8.5 Output Well-Formedness and Determinism For each output slot `i ∈ {0, 1, 2}` (corresponding to public output `noteBodyCommitment_i`), the circuit witnesses `ownerNullifierKeyHash_i`, `noteSecret_i`, `amount_i`, `tokenAddress_i`, and an `isDummy_i` flag constrained to `0` or `1`. Subscripted fields are slot-local; bare `amount` is the transaction-intent amount. For every output slot `i`, regardless of whether it is real or dummy, the circuit MUST: * deterministically derive `noteSecret_i`, * compute `ownerCommitment_i = poseidon(OWNER_COMMITMENT_DOMAIN, ownerNullifierKeyHash_i, noteSecret_i)`, * compute `noteBodyCommitment_i = poseidon(NOTE_BODY_COMMITMENT_DOMAIN, ownerCommitment_i, amount_i, tokenAddress_i)`, * enforce `noteBodyCommitment_i == public noteBodyCommitment_i`. Then: * If `isDummy_i == 0` (real output): * real outputs MUST have `amount_i > 0`. * If `isDummy_i == 1` (dummy output): * `amount_i == 0`, * `tokenAddress_i == 0`, * `ownerNullifierKeyHash_i == DUMMY_OWNER_NULLIFIER_KEY_HASH`. Additional per-mode constraints. The sender's `ownerNullifierKeyHash` is the value extracted from the opened auth-policy registry leaf (Section 8.1). * **Transfer** * output slot 0 is the recipient payment: `isDummy_0 == 0`, `ownerNullifierKeyHash_0` MUST equal `recipientOwnerNullifierKeyHash` (from `transactionIntentDigest`), `ownerNullifierKeyHash_0` MUST NOT equal `0`, `ownerNullifierKeyHash_0` MUST NOT equal `DUMMY_OWNER_NULLIFIER_KEY_HASH`, `amount_0` MUST equal the authorized private amount, and `tokenAddress_0` MUST equal the authorized private token. * output slot 1 is sender change or dummy: if `isDummy_1 == 0`, `ownerNullifierKeyHash_1` MUST equal the sender's `ownerNullifierKeyHash`. * output slot 2 is a fee note or dummy. * `publicRecipientAddress` (from `transactionIntentDigest`) MUST equal `0`. * **Withdrawal** * output slot 0 is sender change or dummy: if `isDummy_0 == 0`, `ownerNullifierKeyHash_0` MUST equal the sender's `ownerNullifierKeyHash`. * output slot 1 MUST be dummy. * output slot 2 is a fee note or dummy. * `recipientOwnerNullifierKeyHash` (from `transactionIntentDigest`) MUST equal `0`. * `publicRecipientAddress` (from `transactionIntentDigest`) MUST equal the public input `publicRecipientAddress`. For output slot 2 specifically: * `feeAmount == 0` iff output slot 2 is dummy, in which case `feeNoteRecipientOwnerNullifierKeyHash == 0`. * `feeAmount > 0` iff output slot 2 is real, in which case: `amount_2 == feeAmount`; `feeNoteRecipientOwnerNullifierKeyHash` MUST NOT be `0` and MUST NOT be `DUMMY_OWNER_NULLIFIER_KEY_HASH`; `ownerNullifierKeyHash_2` MUST equal `feeNoteRecipientOwnerNullifierKeyHash`. The note secret MUST be deterministically derived for both real and dummy ordinary outputs: ``` noteSecret_i = poseidon( TRANSACT_NOTE_SECRET_DOMAIN, noteSecretSeed, intentReplayId, i ) ``` Note-secret derivation is deterministic given a fixed witness assignment. Coin selection and output assignment are not canonicalized. #### 8.6 Output Note Data and Output Binding `outputNoteDataHash0`, `outputNoteDataHash1`, and `outputNoteDataHash2` are public inputs that bind opaque note-delivery payloads to the proof. They are computed as `outputNoteDataHash_i = uint256(keccak256(outputNoteData_i)) mod p`, where `p` is the BN254 scalar field order (Section 3.1). The `mod p` reduction is required because each public input must be a canonical BN254 scalar field element (Section 3.5), and a raw `keccak256` output can exceed `p`. The prover and the contract independently compute this value and verify equality. For each slot `i`, the pool circuit MUST compute: ``` outputBinding_i = poseidon( OUTPUT_BINDING_DOMAIN, noteBodyCommitment_i, outputNoteDataHash_i ) ``` Execution constraints MAY lock any subset of these `outputBinding_i` values. If a slot is locked, the prover cannot change either the semantic note contents or the emitted payload bytes for that slot after authorization. The final inserted `noteCommitment` includes a contract-assigned leaf index and is therefore not itself the authorization-lock target. The pool and auth circuits do not validate encryption scheme semantics or delivery format. #### 8.7 Intent Replay ID All private-note spends use the same intent replay ID derivation: ``` intentReplayId = poseidon( INTENT_REPLAY_ID_DOMAIN, ownerNullifierKey, authorizingAddress, executionChainId, nonce ) ``` Reusing the same `nonce` within the same `(ownerNullifierKey, authorizingAddress, executionChainId)` replay domain makes those authorizations mutually exclusive even when their payment fields or execution constraints differ. Wallets MUST choose a fresh uniformly-random nonce with at least 128 bits of entropy for each new authorization. The derived `intentReplayId` MUST equal public input `intentReplayId`. #### 8.8 Token Consistency All real input and output notes MUST use the same `tokenAddress`. * **Withdrawal**: `tokenAddress == publicTokenAddress`. * **Transfer**: `publicTokenAddress == 0`. #### 8.9 Transaction Intent Digest The auth circuit authenticates this digest; the pool circuit recomputes it from witnesses, public inputs, and mode-derived values and enforces equality. ``` transactionIntentDigest = poseidon( TRANSACTION_INTENT_DIGEST_DOMAIN, authVerifier, authorizingAddress, operationKind, tokenAddress, recipientOwnerNullifierKeyHash, amount, feeNoteRecipientOwnerNullifierKeyHash, feeAmount, publicRecipientAddress, executionConstraintsFlags, lockedOutputBinding0, lockedOutputBinding1, lockedOutputBinding2, nonce, validUntilSeconds, executionChainId ) ``` * `recipientOwnerNullifierKeyHash` MUST be `< p`. For transfer-mode authorizations it is the recipient's `ownerNullifierKeyHash` and MUST NOT be `0` or `DUMMY_OWNER_NULLIFIER_KEY_HASH`. For withdrawal-mode authorizations it MUST be `0`. * `feeNoteRecipientOwnerNullifierKeyHash` MUST be `< p`. When `feeAmount > 0` it is the fee-note recipient's `ownerNullifierKeyHash` and MUST NOT be `0` or `DUMMY_OWNER_NULLIFIER_KEY_HASH`. When `feeAmount == 0` it MUST be `0`. * `publicRecipientAddress` MUST be `< 2^160`. For withdrawal-mode authorizations it is the public withdrawal destination and MUST equal the public input `publicRecipientAddress`. For transfer-mode authorizations it MUST be `0`. * `nonce` MUST be uniformly random (Section 8.7). It supplies replay protection and prevents brute-force confirmation of the digest preimage. The pool circuit MUST derive `operationKind` from the public execution mode: * `publicAmountOut > 0` → `WITHDRAWAL_OP` * `publicAmountOut == 0` → `TRANSFER_OP` **Normative execution-field binding** * **Withdrawal** * `publicRecipientAddress` in intent == public input `publicRecipientAddress` * `amount == publicAmountOut` * `tokenAddress == publicTokenAddress` * `validUntilSeconds` == public input * `executionChainId == block.chainid` (checked by contract) * `recipientOwnerNullifierKeyHash == 0` * `feeNoteRecipientOwnerNullifierKeyHash` and `feeAmount` bound through intent-digest computation and Section 8.5. * **Transfer** * `recipientOwnerNullifierKeyHash`, `amount`, `feeNoteRecipientOwnerNullifierKeyHash`, and `feeAmount` are private, bound through intent-digest computation, output constraints, and value conservation. * `tokenAddress` is private, bound through token consistency (Section 8.8). * `validUntilSeconds` == public input * `executionChainId == block.chainid` (checked by contract) * `publicRecipientAddress` in intent == `0`; public input `publicRecipientAddress == 0`. * `publicAmountOut == 0` * `publicTokenAddress == 0` #### 8.10 Execution Constraints Execution constraints let an authorization optionally bind finalized output slots without changing the nonce-based replay domain. The authorization-bound fields `executionConstraintsFlags`, `lockedOutputBinding0`, `lockedOutputBinding1`, and `lockedOutputBinding2` are inputs to `transactionIntentDigest` (Section 8.9). * `executionConstraintsFlags < 2^32`. Any bit other than `LOCK_OUTPUT_BINDING_0`, `LOCK_OUTPUT_BINDING_1`, `LOCK_OUTPUT_BINDING_2` MUST cause proof failure. * For each `i ∈ {0, 1, 2}`: if `executionConstraintsFlags & LOCK_OUTPUT_BINDING_i != 0`, then `lockedOutputBinding_i == outputBinding_i`; otherwise `lockedOutputBinding_i == 0`. ### 9. Public Inputs The pool proof's public-input vector is the 19 fields of `PublicInputs`, in declaration order. Each `uint256` field is interpreted by the Groth16 verifier as a single BN254 scalar field element per Section 3.5. * `noteCommitmentRoot` — note-commitment-tree root the proof is verified against. * `nullifier0`, `nullifier1` — input note nullifiers. * `noteBodyCommitment0`, `noteBodyCommitment1`, `noteBodyCommitment2` — semantic output note commitments. * `publicAmountOut` — public withdrawal amount; 0 for transfers. * `publicRecipientAddress` — withdrawal destination address; 0 for transfers. * `publicTokenAddress` — withdrawn token address; 0 for transfers. * `intentReplayId` — replay protection. * `validUntilSeconds` — intent expiry timestamp. MUST be > 0 and < `2^32`. * `executionChainId` — verified by the contract against `block.chainid`. * `authPolicyRoot` — auth-policy registry root. MUST be nonzero. * `outputNoteDataHash0`, `outputNoteDataHash1`, `outputNoteDataHash2` — `uint256(keccak256(outputNoteData_i)) mod p`; see Section 8.6. * `authVerifier` — address of the auth verifier contract dispatched to in Section 5.4.1 step 8. MUST be nonzero and `< 2^160`. * `blindedAuthCommitment` — the value also taken as the auth proof's first public input. * `transactionIntentDigest` — the value also taken as the auth proof's second public input. `executionConstraintsFlags`, `lockedOutputBinding0`, `lockedOutputBinding1`, `lockedOutputBinding2`, `nonce`, `authDataCommitment`, `blindingFactor`, `recipientOwnerNullifierKeyHash`, and `feeNoteRecipientOwnerNullifierKeyHash` are private authorization-bound values checked inside the proof relation. `ownerNullifierKeyHash`, `noteSecretSeedHash`, `policySetCommitment`, `policyCommitment`, `registrationBlinder`, and `leafPosition` are private registry witnesses. #### 9.1 Public Input Range Validation Every public input MUST be a canonical BN254 scalar field element (`< p`); the system contract rejects any non-canonical value (Section 5.5). In addition, the system contract enforces the following per-field range checks at Section 5.4.1 step 6: `publicAmountOut < 2^248`; `publicRecipientAddress < 2^160`, `publicTokenAddress < 2^160`, `authVerifier < 2^160`, `authVerifier != 0`; `validUntilSeconds < 2^32`; `executionChainId < 2^32`. These checks prevent non-address values aliasing into EVM-address slots and prevent amount overflow in the balance equation. ### 10. Poseidon Hash Contexts Inputs are listed in declaration order. Each input is a single BN254 scalar field element (Section 3.5); the Section 3.3 length-tagged sponge consumes them in 3-element chunks. Arity is the number of input field elements (excluding length-tag bookkeeping inside the sponge state). | Context | Inputs (in order) | Arity | |---------|-------------------|-------| | ownerNullifierKeyHash | `OWNER_NULLIFIER_KEY_HASH_DOMAIN, ownerNullifierKey` | 2 | | ownerCommitment | `OWNER_COMMITMENT_DOMAIN, ownerNullifierKeyHash, noteSecret` | 3 | | noteBodyCommitment | `NOTE_BODY_COMMITMENT_DOMAIN, ownerCommitment, amount, tokenAddress` | 4 | | noteCommitment | `NOTE_COMMITMENT_DOMAIN, noteBodyCommitment, leafIndex` | 3 | | Nullifier | `NULLIFIER_DOMAIN, noteCommitment, ownerNullifierKey` | 3 | | Phantom nullifier | `PHANTOM_NULLIFIER_DOMAIN, ownerNullifierKey, intentReplayId, inputIndex` | 4 | | Note secret seed hash | `NOTE_SECRET_SEED_DOMAIN, noteSecretSeed` | 2 | | Ordinary note secret | `TRANSACT_NOTE_SECRET_DOMAIN, noteSecretSeed, intentReplayId, outputIndex` | 4 | | Intent replay ID | `INTENT_REPLAY_ID_DOMAIN, ownerNullifierKey, authorizingAddress, executionChainId, nonce` | 5 | | Transaction intent digest | `TRANSACTION_INTENT_DIGEST_DOMAIN, authVerifier, authorizingAddress, operationKind, tokenAddress, recipientOwnerNullifierKeyHash, amount, feeNoteRecipientOwnerNullifierKeyHash, feeAmount, publicRecipientAddress, executionConstraintsFlags, lockedOutputBinding0, lockedOutputBinding1, lockedOutputBinding2, nonce, validUntilSeconds, executionChainId` | 17 | | Output binding | `OUTPUT_BINDING_DOMAIN, noteBodyCommitment, outputNoteDataHash` | 3 | | Policy commitment | `POLICY_COMMITMENT_DOMAIN, authVerifier, authDataCommitment, registrationBlinder` | 4 | | Auth policy leaf | `AUTH_POLICY_DOMAIN, user, ownerNullifierKeyHash, noteSecretSeedHash, policySetCommitment` | 5 | | Blinded auth commitment | `BLINDED_AUTH_COMMITMENT_DOMAIN, authDataCommitment, blindingFactor` | 3 | | Merkle tree node | `left, right` | 2 | Address-typed inputs are absorbed as `uint160` field elements; `uint32`-typed inputs as `uint32` field elements; `amount` and `feeAmount` carry an additional in-circuit `< 2^248` constraint (Section 7.1). ### 11. Auth Verifier Contract Each auth circuit has a corresponding `authVerifier` Solidity contract. Anyone may deploy an auth verifier contract; the system contract dispatches to whichever address the user has included in their `policySetCommitment`. #### 11.1 Interface An auth verifier contract MUST implement: ```solidity interface IAuthVerifier { function verifyAuth( bytes calldata publicInputs, bytes calldata proof ) external returns (bool); } ``` * `publicInputs` is exactly `abi.encode(blindedAuthCommitment, transactionIntentDigest)`, where both values are `uint256`. * `proof` is the auth proof bytes in whatever encoding the auth verifier expects. #### 11.2 Verification Semantics The system contract MUST invoke `verifyAuth` via `staticcall` with the auth proof and encoded public inputs taken from the pool proof's public inputs. The system contract MUST treat any of the following as verification failure (and revert the `transact` call): * the staticcall reverts, * returndata length is not exactly 32 bytes, * the decoded boolean return value is `false`, * the auth verifier address has zero code length. The system contract's `staticcall` enforces read-only execution. Any auth verifier behavior that causes the staticcall to fail is treated as proof failure. A malicious or buggy auth verifier can validate proofs that should fail, but cannot extend its compromise beyond identities with a policy registered for that verifier in any accepted `authPolicyRoot`; the pool circuit independently enforces all pool-critical invariants. Companion ERCs SHOULD specify the canonical auth-circuit relation, the `verifyAuth` proof format, and any verification-key derivation rules sufficient for third-party audit. ### 12. Output Note Data Note delivery, meaning how senders convey enough information for recipients to recover output notes, is not specified by this EIP. Wallets MAY coordinate delivery out of band, and companion standards MAY define shared registries or encryption formats. This EIP treats `outputNoteData` bytes as opaque. In `transact`, `outputNoteData_i` is hash-bound as defined in Section 8.6; in `deposit`, `outputNoteData` is emitted opaquely and is not proof-bound. ## Rationale ### System Contract, Fork-Managed Pool Circuit, and No Admin Pause The pool is a protocol-managed account at a fixed address because its security depends on global state, not on a single application. The system contract has no upgrade key, no proxy, and no pause path. Changes require a hard fork. A bug in the ZK scheme can compromise funds held in the pool but does not alter consensus rules, the validator set, or ETH supply semantics. Native integration (e.g., [EIP-7503](./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 deposit-only pause triggered by a consensus-layer flag was considered and rejected. Any pause trigger reintroduces a governance surface; a withdrawal freeze during a false alarm locks user funds pending a hard fork to unpause. The scope of a soundness exploit (pool-held funds only, not protocol consensus) makes the hard-fork remediation timeline acceptable relative to the governance risk of a pause mechanism. ### Split Proof Architecture and Private Auth-Policy Registration Managing specific auth methods at the protocol level would freeze users to one scheme or require a hard fork per addition. Splitting authorization out into permissionless `authVerifier` contracts lets methods evolve without protocol changes. The blinded registration is what keeps adding a method from fragmenting the anonymity set: each `(authVerifier, authDataCommitment)` pair is hidden inside `policyCommitment` under `registrationBlinder`, and `blindingFactor` rerandomizes `blindedAuthCommitment` each transaction so it cannot be linked back to a specific registration. ### Credential-Proof Separation The auth circuit consumes the user's authorization credential (typically a signature) as a witness; the pool circuit never sees it. Producing the credential is orders of magnitude cheaper than producing the pool proof, so the device that authorizes a spend does not have to be the device that proves it. This enables hardware wallets (sign on a constrained device, prove on a capable one), delegated proving services (hand a credential plus state witnesses to a third-party prover without giving up signing authority), and async signing flows (sign now, prove later when a capable device is available). A combined single-circuit design that required the signer to also generate the proof would foreclose all of these. ### Groth16 BN254 Pool Proof System Soundness rests on Poseidon2 collision-/preimage-resistance, the BN254 q-DLOG / pairing assumptions underlying Groth16, and a one-time multi-party trusted-setup ceremony. Groth16 BN254 has the smallest proof size and verifier gas cost of the major BN254 SNARK families; native mobile provers (e.g. rapidsnark) ship prebuilt for iOS/Android arm64. The Section 3.3 length-tagged sponge initializes capacity to `N << 64`, so a 2-input Merkle node and any arity-`≥ 3` application hash start in distinct sponge states; the spec therefore omits a `MERKLE_NODE_DOMAIN` tag without weakening cross-context collision resistance. Pool-proof verification gas is dominated by `ECADD` / `ECMUL` / `ECPAIRING` calls on the 19 public-input scalar multiplications and the final pairing. ### Future PQ Migration Groth16 over BN254 is not post-quantum secure. Two PQ adaptations are available together: 1. The proof system is fork-swappable via system contract code replacement, and the on-chain state schema (tree shapes, domain tags, preimage layouts, public-input layout, intent format) is defined independently of the proof system. A future verifier consuming the same logical relation accepts the same state. The swap is a single hard-fork event. 2. Users can rotate to PQ-secure auth methods well before quantum capability materializes: register a PQ `policyCommitment`, call `setAuthPolicy` with a `policySetCommitment` containing only PQ methods, then wait for the auth-policy root-history window to expire. After the window, no classical-method proof can land. Notes do not need to be moved; only the policy set rotates. A post-quantum proof system was not selected for the pool at activation because even at aggressive parameters a direct STARK proof exceeds the practical L1 calldata target (>167 KB in our measurements). This EIP therefore preserves both verifier-swap and per-identity policy-rotation paths rather than imposing post-quantum proving costs at activation. ### Hidden Owner IDs and Address Scope Notes commit to hidden `ownerNullifierKeyHash` values; recipients are identified at the protocol layer by `ownerNullifierKeyHash`, not by Ethereum address. Wallets resolve addresses to `ownerNullifierKeyHash` off-chain via companion-standard discovery. The Ethereum address remains the authorization-and-administration namespace through the auth-policy registry (`setAuthPolicy` is `msg.sender`-gated and the leaf binds `user = msg.sender`), but the address does not appear inside note commitments or as a private recipient field. The one Ethereum address that surfaces in the protocol is `publicRecipientAddress`, which names the EVM-level destination of a withdrawal and is bound into `transactionIntentDigest`. ### Proof-Free Deposits Deposits are public asset movements into the pool. Making them contract-native avoids spending proof overhead where no private-note input is being consumed. Private-note spending still requires a proof and remains the hard security boundary. ### Constrained vs Wallet-Chosen Note Secrets `transact` and `deposit` treat `noteSecret` differently by design. In `transact`, an unconstrained `noteSecret` would give the prover discretion over note openings and recovery-sensitive randomness. The pool circuit therefore pins `noteSecret = poseidon(TRANSACT_NOTE_SECRET_DOMAIN, noteSecretSeed, intentReplayId, outputIndex)`, tying it to the authorizing address's registered seed, the authorization nonce, and the output slot. In `deposit`, there is no prover to discipline: the depositor constructs the note directly and conveys whatever `noteSecret` they chose to the recipient via `outputNoteData` or out of band. Nullifier uniqueness no longer depends on `noteSecret` structure — the contract-assigned `leafIndex` carries that role — so removing the protocol-level derivation from the deposit path does not weaken any safety invariant. ### Two-Layer Note Commitment Splitting note creation into `ownerCommitment`, `noteBodyCommitment`, and final `noteCommitment` lets ordinary private-note spends preserve privacy while letting deposits and contract-completed flows finalize note insertion with a contract-assigned leaf index. Output locking binds the semantic note (`noteBodyCommitment`) plus payload hash rather than the insertion-specific final leaf. ### Leaf-Index Uniqueness Using the assigned leaf index in the final note commitment guarantees uniqueness even when two notes share the same semantic contents. This removes nullifier-collision dependence on note-secret derivation structure while still requiring wallets to avoid note-secret reuse for privacy. ### Out-of-Protocol Compliance This EIP does not include any in-protocol compliance primitives — origin tags, allowlist identifiers, risk scores, or provenance propagation rules. Encoding a specific compliance model at the protocol layer is less expressive than what can be built on top, commits the protocol to one model prematurely, and makes the compliance surface subject to hard-fork governance rather than companion-standard iteration. Disclosure formats and compliance workflows belong in companion standards and off-chain infrastructure built over the public deposit and withdrawal record. ### Finalized Output Binding `outputBinding = poseidon(OUTPUT_BINDING_DOMAIN, noteBodyCommitment, outputNoteDataHash)` binds one emitted semantic note commitment to one output-note-data hash. Execution constraints use this binding to lock finalized output slots. ### Private Fee Compensation The system contract charges no protocol-level fee. The protocol's mandatory onchain cost is Ethereum gas. Prover or broadcaster compensation, if any, is optional and user-authorized via output slot 2 rather than imposed by the pool. Private transfers need a way to compensate a broadcaster or sponsor without revealing the transferred token on-chain. A public fee output would leak the asset for shielded transfers, so this EIP reserves output slot 2 for an optional private fee note. The authorization binds `feeNoteRecipientOwnerNullifierKeyHash` directly: wallets resolve the fee recipient off-chain and include their `ownerNullifierKeyHash` in the signed intent. The circuit enforces that output slot 2's owner-hash equals the bound value. Keeping fee compensation inside the same note model also makes the design compatible with both legacy transactions and future transaction types that separate sender from gas payer: the transaction layer can decide who submits and who pays gas, while the pool continues to express compensation as an ordinary shielded note in the transferred asset. ### UTXO-Based Notes over Account-Based Encrypted Balances Account-based encrypted balances reveal access patterns — which accounts transact and how frequently — even when amounts are hidden. UTXO-based notes avoid this: spending a note produces new commitments, and shielded transfers within the pool reveal nothing about amounts, tokens, or counterparties on-chain. ## Backwards Compatibility This EIP defines a new system contract and a new pool proof relation, all activated by the same hard fork. It does not modify the semantics of existing contracts or existing ERC-20 interfaces. ## Test Cases Normative test coverage MUST include at least: * Poseidon2/BN254 parameter and vector assets load and verify. * Pool proof verification in `transact` accepts a valid Groth16 pool proof against the embedded VK with a 19-field public-input vector, and rejects malformed encodings, non-canonical public inputs (`>= p`), or pairing failure; pool-proof rejection also when `authVerifier == 0`, `>= 2^160`, or has no deployed code. * `setAuthPolicy`: first-call identity assignment, subsequent-call `ownerNullifierKeyHash` immutability, `noteSecretSeedHash` rotation accepted, global `ownerNullifierKeyHash` uniqueness rejection, reserved-value rejection (`0`, `DUMMY_OWNER_NULLIFIER_KEY_HASH`), canonical-input rejection (`>= p`), `nextLeafPosition < 2^32` boundary. * Auth-proof envelope malformed-bytes rejection; auth-verifier dispatch failure (staticcall revert, returndata not 32 bytes, or decoded `false`). * Address, amount, and public-input range rejection: `publicRecipientAddress`/`publicTokenAddress` `>= 2^160`, `publicAmountOut >= 2^248`, `validUntilSeconds >= 2^32`, `executionChainId >= 2^32`. * Root-history boundary acceptance and rejection for `noteCommitmentRoot` and `authPolicyRoot`. * Auth-policy registry leaf membership rejection when the witnessed `leafPosition` opens to a leaf that does not match `poseidon(AUTH_POLICY_DOMAIN, uint160(authorizingAddress), ownerNullifierKeyHash, noteSecretSeedHash, policySetCommitment)` for the witnessed values. * Policy-set membership rejection: witnessed `policyCommitment` is not in `policySetCommitment`; `policyCommitment == 0` is rejected; empty-policy-set unspendability (a user submits an empty-set `policySetCommitment` and no subsequent spend succeeds against the current root). * `leafPosition` range rejection. * Cross-proof binding rejection: pool and auth proofs disagreeing on `(blindedAuthCommitment, transactionIntentDigest)` MUST fail. * Recipient and fee-recipient `ownerNullifierKeyHash` reserved-value rejection (`0` and `DUMMY_OWNER_NULLIFIER_KEY_HASH`). * Withdrawal `publicRecipientAddress` binding: a prover-substituted destination not matching the value in `transactionIntentDigest` MUST be rejected. * Dummy-output constraint failures. * ETH deposit. * Token deposit. * Deposit rejection for fee-on-transfer tokens. * Transfer with two real inputs. * Transfer with one real input and one phantom input. * Withdrawal with change. * Output binding locks over `noteBodyCommitment`. * Final note commitment reconstruction from `noteBodyCommitment` and assigned leaf index. * Nullifier uniqueness for distinct final note commitments even when note semantic contents match. * Reserved-flag-bit rejection. * Locked-slot mismatch rejection. Implementations SHOULD additionally test: * Tree-capacity failure at the depth-32 boundary for both the note-commitment tree and the auth-policy registry. * Finalized-output-binding and nonce-replay cases: * changing only execution constraints changes `transactionIntentDigest` but not `intentReplayId`, * reusing the same nonce across otherwise distinct authorizations yields the same `intentReplayId`, * fresh nonce changes both `transactionIntentDigest` and `intentReplayId` when all other fields remain the same, * a locked slot succeeds when `lockedOutputBinding{i} == poseidon(OUTPUT_BINDING_DOMAIN, noteBodyCommitment_i, outputNoteDataHash_i)`, * a locked slot fails if `noteBodyCommitment_i` changes while `outputNoteDataHash_i` stays fixed, * a locked slot fails if `outputNoteDataHash_i` changes while `noteBodyCommitment_i` stays fixed, * an unlocked slot accepts `lockedOutputBinding{i} = 0` without requiring equality to `poseidon(OUTPUT_BINDING_DOMAIN, noteBodyCommitment_i, outputNoteDataHash_i)`. * Auth-method rotation: a fresh `policySetCommitment` is registered, and after the auth-policy root-history window expires, spends against the prior `policySetCommitment` are rejected. * Seed rotation: spends against the new root use the new `noteSecretSeed` for output derivation, while spends against in-window historical roots use the prior seed. ## Security Considerations ### Multi-Auth Security Boundary Each `policyCommitment` in an address's `policySetCommitment` is an independent spend-authorization path for notes bound to that address's `ownerNullifierKeyHash`. Including a weak auth method alongside a strong one widens the attack surface, but spending still also requires custody of `ownerNullifierKey` and the relevant proving material. ### Auth Verifier Trust A user who includes an auth policy for an `authVerifier` address in their `policySetCommitment` trusts that address to correctly verify auth proofs. A malicious or buggy auth verifier can validate auth proofs that should fail, allowing whoever can construct such a proof to spend that user's notes. The compromise is bounded: only identities with a policy registered for that specific address in any accepted `authPolicyRoot` are at risk, and only for spends that go through that auth verifier. Other auth policies registered by the same user are unaffected. ### DoS via Root History Prolonged congestion can cause proofs against stale roots to fail before submission. The note-commitment root history is a fixed-size circular buffer that advances on every `transact` and every `deposit`. Under sustained high throughput, users must submit proofs before the buffer wraps past their proven root. ### Metadata Leakage Deposits and withdrawals are public by design. Deposits reveal depositor, token, and amount. Private transfers keep token and amount private and reveal which `authVerifier` was used — and through it which auth method — but private registration (Section 6.1) keeps the user-to-verifier mapping off-chain, so the apparent anonymity set seen by an observer is every registered identity rather than only the users of the visible `authVerifier`. The actual sender set is a subset of that (identities with a policy registered for this `authVerifier` in any accepted `authPolicyRoot`), but observers cannot collapse apparent to actual without breaking `registrationBlinder`. Output note data may leak metadata depending on the delivery scheme and wallet payload conventions in use. ### Chain-Level Linkability of Self-Reshield Flows A self-reshield flow — `transact` withdrawal to a public helper contract, public swap or other public execution, then `deposit` of the result back into the pool — is chain-level-linkable even though the reshielded note itself is private. The withdrawing EOA, the swap, and the `deposit` call are all public transactions attributable to the same initiator, and their composition is observable. The privacy property this flow provides is post-swap anonymity: the reshielded note joins the general note anonymity set and its eventual spend is indistinguishable from any other private-note spend. The flow does not make the swap itself private, and it does not delink the initiator from the act of shielding. Any atomic external swap against a public venue has this property regardless of the shielded-pool design. ### State Growth The pool accumulates append-only state for note commitments, nullifiers, and intent replay IDs. These values cannot be safely pruned without breaking spend or replay protection. ### Output Note Data Leakage and Sabotage `outputNoteData` payloads are opaque and on-chain. Their size and structure can leak metadata: empty or variable-size dummy payloads can leak which outputs are real. A malicious sender, prover, or coordinator can also emit unusable `outputNoteData` and make note recovery fail. This cannot steal funds or redirect payment, but it can break recipient recovery. Wallet-layer or companion-standard delivery formats SHOULD use constant-size payloads to limit structural leakage. ### Auth-Policy Root History and Deactivation Delay The auth-policy registry uses a block-based root history with window `AUTH_POLICY_ROOT_HISTORY_BLOCKS` (Section 5.2.1). The at-most-one-entry-per-block aging rule prevents same-block churn from burning multiple history slots; an attacker churning updates across blocks can fill history with attacker-controlled roots without affecting other users' ability to spend against any in-window legitimate root. Updates to an address's leaf — revoking policies, rotating `noteSecretSeed`, or changing the auth method set — are not instantaneous: the pre-update root remains accepted for up to `AUTH_POLICY_ROOT_HISTORY_BLOCKS` blocks, during which spends against historical roots continue to use the prior policy set and seed. Users planning a post-quantum migration or other security-motivated rotation MUST rotate sufficiently in advance of the threat. Wallets that need cancellation semantics tighter than the window SHOULD rely on short `validUntilSeconds` windows and nonce consumption. ### Registration Hygiene Losing `(authVerifier, authDataCommitment, registrationBlinder)` for a specific policy makes that policy unusable for spending but does not affect fund access — notes bind to `ownerNullifierKeyHash`, not to any single auth policy — so the user can register a fresh policy under the same identity by recomputing `policySetCommitment` and calling `setAuthPolicy`. `nonce` and `blindingFactor` MUST be drawn from a cryptographic RNG with at least 128 bits of effective entropy: low-entropy `nonce` allows a digest-preimage brute force; low-entropy `blindingFactor` plus a guessable `authDataCommitment` deanonymizes the registered auth data from `blindedAuthCommitment`. ### noteSecret Reuse Reusing `noteSecret` across notes does not by itself create nullifier collisions in this design because nullifiers are derived from final note commitments that include the assigned leaf index. It does, however, create linkability and degrades privacy. Wallets MUST avoid `noteSecret` reuse. ### Deposits Are Permissionless The contract accepts opaque `ownerCommitment` values on `deposit` and does not require the recipient to have called `setAuthPolicy`. An unregistered recipient may register later before spending. Senders resolve the recipient's `ownerNullifierKeyHash` and any delivery information off-chain via a companion standard. Recipients SHOULD claim their `ownerNullifierKeyHash` via `setAuthPolicy` before publishing it externally; the contract enforces global uniqueness, and a published unclaimed `ownerNullifierKeyHash` can be permanently claimed by any address calling `setAuthPolicy` first, locking the original generator out. ### Unlocked Output Slots If an authorization leaves an output slot unlocked (Section 8.10), the prover may choose that slot's `outputNoteData` and any otherwise-unpinned note details subject to the pool circuit's normal constraints. This cannot steal funds or override authorized payment fields, but malformed or unrecoverable `outputNoteData` can break recipient recovery. Authorizations that need finalized slot contents pinned SHOULD lock the corresponding `outputBinding`. ### Pool Proof System Assumptions Soundness rests on a one-time multi-party trusted-setup ceremony for the canonical pool circuit: at least one participant must honestly destroy their toxic-waste contribution. Verifier upgrades altering the pool circuit's R1CS shape MUST run a fresh ceremony. Under quantum adversaries, commitments and nullifiers (254-bit Poseidon2/BN254) sit at `≈2^111` BHT for the dominant note-commitment-tree multi-target preimage at depth-32 saturation, with nullifier collisions at the `≈2^84` BHT floor (DoS-only, second spend reverts). The `outputNoteDataHash_i mod p` reduction (Section 8.6) is bias-negligible. The pool proof system is classical Groth16 BN254 (future PQ migration in Rationale). ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).