Alert Source Discuss
⚠️ Draft Standards Track: Core

EIP-8182: Private ETH and ERC-20 Transfers

A canonical validity layer for private ETH and compatible ERC-20 transfers via a system contract and verifier precompile.

Authors Tom Lehman (@RogerPodacter)
Created 2026-03-03
Discussion Link https://ethereum-magicians.org/t/eip-8182-private-eth-and-erc-20-transfers/27889
Requires EIP-20

Abstract

This EIP introduces protocol-level private ETH and compatible ERC-20 transfers with public deposits and withdrawals, implemented as a system contract with a companion proof-verification precompile. A recursive proof architecture separates protocol invariants enforced by a hard-fork-managed outer circuit from permissionless inner authentication circuits, allowing users to choose compatible authentication methods — such as ECDSA, passkeys, or multisig — without requiring a hard fork for each new auth method. The same system contract also exposes an optional delivery-key registry for standardized on-chain note-delivery key discovery 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 noteSecrets, 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_HASHposeidon(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. The corresponding normative vectors are in the Poseidon vector asset.

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 noteSecrets 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 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. 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 setmapping(uint256 => bool).
  • Transaction replay ID setmapping(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 registrymapping(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 versionsmapping(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:

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:

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:

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.

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:

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:

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:

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

Auth policy deregistration:

function deregisterAuthPolicy(
    uint256 innerVkHash
) external

deregisterAuthPolicy is called by msg.sender to remove an auth policy. The contract 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:

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:

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:

    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:

    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 != 0DEPOSIT_OP; depositorAddress == 0 AND publicAmountOut > 0WITHDRAWAL_OP; depositorAddress == 0 AND publicAmountOut == 0TRANSFER_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.
  • outputNoteDataHash0uint256(keccak256(outputNoteData0)) % p. Binds the first output’s opaque note-delivery payload to the proof.
  • outputNoteDataHash1uint256(keccak256(outputNoteData1)) % p. Binds the second output’s opaque note-delivery payload to the proof.
  • outputNoteDataHash2uint256(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, outer_vk.sha256, and 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 together with 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 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:

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

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 and related rights waived via CC0.

Citation

Please cite this document as:

Tom Lehman (@RogerPodacter), "EIP-8182: Private ETH and ERC-20 Transfers [DRAFT]," Ethereum Improvement Proposals, no. 8182, March 2026. Available: https://eips.ethereum.org/EIPS/eip-8182.