--- eip: TBD title: Protocol-Enshrined Privacy Pool description: Shielded transfers via enshrined system contract with wallet-compatible intent authorization author: Tom Lehman discussions-to: <Ethereum Magicians URL> status: Draft type: Standards Track category: Core created: 2026-02-27 --- # EIP-XXXX: Protocol-Enshrined Privacy Pool ## Abstract This EIP introduces a protocol-enshrined privacy pool deployed at a fixed Ethereum address, enabling shielded transfers and withdrawals with a single canonical note tree, nullifier set, and registry infrastructure. Users authorize shielded transfers and withdrawals by signing standard EIP-1559 (type-2) Ethereum transactions on reserved "intent" chain IDs; a relayer submits the corresponding on-chain privacy pool transaction containing a zero-knowledge proof, public inputs, and encrypted note payloads. Deposits are public and authorized by `msg.sender`. The privacy pool is deployed as a system contract whose code can only be replaced by a consensus upgrade (hard fork). This EIP also specifies required precompiles for Poseidon hashing and proof verification, a user registry binding Ethereum addresses to encryption and nullifier keys, an issuer registry enabling scoped token-issuer visibility, and a label-based lineage system supporting proof of innocence. ## Motivation Ethereum transactions and balances are public by default, creating substantial friction for everyday financial use cases. App-level privacy protocols face three persistent problems: 1. New protocols struggle to bootstrap meaningful anonymity sets. 2. Upgradeable contracts introduce governance risk — a malicious upgrade can drain the pool. 3. Immutable contracts cannot evolve to adopt better cryptography or authentication. Protocol enshrinement resolves all three: the anonymity set is canonical from day one, upgrades require the same social-consensus process as any other protocol change (hard fork), and the system can evolve across forks. Ethereum defines what a valid public transaction is; it should also define what a valid private transaction is. Alongside enshrinement, this EIP defines a transaction standard: the user's intent is conveyed as a normal-looking signed transfer on a fictional chain ID, and the proof circuit operates over this specific signed format. Any existing wallet can produce a valid intent without modification. The standard is useful independently — app-level pools can adopt the same transaction format and proof system, backed by a verifier precompile that the protocol keeps current. ## 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 **Privacy Pool System Contract** deployed at a fixed address, holding all privacy state (note commitment tree, nullifier set, intent-nullifier set, user registry, issuer registry). 2. A **hard-fork-only upgrade model**: the system contract's code can only be replaced by a hard fork; there is no proxy or admin function. 3. A **wallet-compatible intent authorization format**: users sign standard EIP-1559 (type-2) transactions on reserved chain IDs to authorize shielded transfers and withdrawals. 4. A **public-input interface** for spend proofs and required contract execution checks. 5. A **label-based lineage system** enabling proof of innocence without fragmenting the anonymity set. 6. An **issuer viewing key registry** enabling scoped visibility for permissioned asset issuers. 7. Two **precompiles**: a Poseidon hashing precompile (REQUIRED) and a proof verification precompile (REQUIRED for the canonical deployment). App-level policy (e.g., proof-of-innocence enforcement, compliance wrappers, fees) is out of scope for the base contract and MAY be implemented by wrapper contracts. ### 2. Terminology * **Note**: A shielded UTXO-like object represented on-chain by a commitment. * **Commitment**: A Poseidon hash committing to a note's fields. * **Nullifier**: A value published when spending a note to prevent double-spends. * **Label**: A cryptographic lineage tag tracing a note back to its original deposit(s). Used for proof of innocence. * **Intent transaction (intent tx)**: A signed EIP-1559 (type-2) transaction on a reserved intent chain ID that authorizes a shielded transfer or withdrawal. The intent tx is never broadcast to any real chain. * **On-chain transaction (pool tx)**: A normal Ethereum transaction calling the privacy pool contract with a proof and public inputs. * **Privacy RPC**: An RPC endpoint that intercepts intent transactions, generates proofs, and submits pool transactions on the user's behalf. * **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 (2 outputs) while producing fewer real notes. * **Registry (user registry)**: A Merkleized mapping from `address` to `(viewingPubKey, nullifierKeyHash)`, binding Ethereum addresses to encryption and nullifier keys. * **Issuer registry**: A Merkleized mapping from `tokenAddress` to `(issuerPubKey, issuerEpoch)`, enabling optional issuer visibility. * **Association Set Provider (ASP)**: A party that publishes Merkle roots over sets of deposit labels it considers clean, enabling proof of innocence. ### 3. Parameters and Constants The following items MUST be concretely assigned before this EIP can advance beyond Draft: * `PRIVACY_POOL_ADDRESS` — fixed address for the system contract * `POSEIDON_PRECOMPILE_ADDRESS` * `PROOF_VERIFY_PRECOMPILE_ADDRESS` * Proof system and verification key format(s) * Merkle tree depth (v0 RECOMMENDED: 32, supporting ~4B leaves) * Root-history buffer sizes (v0 RECOMMENDED: >= 500) The following values are defined structurally: #### 3.1 Reserved Intent Chain IDs For each execution chain with chain ID `E` (`E = block.chainid`), two intent chain IDs are derived deterministically: ``` TRANSFER_CHAIN_ID(E) = uint64(uint256(keccak256(abi.encode("PRIVACY_POOL_TRANSFER", uint256(E))))) WITHDRAWAL_CHAIN_ID(E) = uint64(uint256(keccak256(abi.encode("PRIVACY_POOL_WITHDRAWAL", uint256(E))))) ``` The `uint64` truncation ensures compatibility with wallet and hardware-wallet signing stacks. `TRANSFER_CHAIN_ID(E)` and `WITHDRAWAL_CHAIN_ID(E)` are distinct from each other and from `E` with overwhelming probability — `uint64(keccak256(...))` yields collision probability ~1/2^64 per chain pair, which is negligible across all deployed EVM chains. A leaked intent tx is a valid Ethereum transaction — if its chain ID matched a real chain, it could be replayed there. Intent chain IDs are per-execution-chain, so an intent signed for one chain's pool cannot be replayed on another chain's pool — the chain ID in the ECDSA signature will not match the target chain's expected intent chain IDs. #### 3.2 Packed Intent Nonce For intent transactions, the nonce MUST be interpreted as: ``` nonce = (validUntilSeconds << 32) | random32 ``` * `validUntilSeconds` — unsigned 32-bit UNIX timestamp providing bounded intent lifetime. * `random32` — uniformly random 32-bit value providing `intentNullifier` uniqueness. The Privacy RPC MUST return a packed nonce when the wallet queries `eth_getTransactionCount` on the fictional chain ID. #### 3.3 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("privacy_pool.<context_name>")) mod p ``` where `p` is the BN254 scalar field modulus (`21888242871839275222246405745257275088548364400416034343698204186575808495617`) and `<context_name>` is the string identifier listed below. This derivation is deterministic and removes all domain tag TBDs. The following domain tags are defined by this EIP: * `NULLIFIER_DOMAIN` — `keccak256("privacy_pool.nullifier") mod p` — real note nullifiers * `PHANTOM_DOMAIN` — `keccak256("privacy_pool.phantom") mod p` — phantom nullifiers * `LABEL_DOMAIN` — `keccak256("privacy_pool.label") mod p` — deposit labels * `LABEL_MERGE_DOMAIN` — `keccak256("privacy_pool.label_merge") mod p` — merged transfer labels * `INTENT_DOMAIN` — `keccak256("privacy_pool.intent") mod p` — intent nullifiers * `NK_DOMAIN` — `keccak256("privacy_pool.nk") mod p` — nullifier key hashing (`nullifierKeyHash = poseidon(NK_DOMAIN, nullifierKey)`) * `RANDOMNESS_DOMAIN` — `keccak256("privacy_pool.randomness") mod p` — deterministic output randomness derivation #### 3.4 Fixed Constants * `MAX_INTENT_LIFETIME = 86400` — maximum allowed `validUntilSeconds` offset from `block.timestamp`, in seconds (24 hours). Long enough to tolerate network congestion; short enough to bound stale intent risk. * `DUMMY_NK_HASH` — a nothing-up-my-sleeve constant computed as `poseidon(NK_DOMAIN, 0xdead)` using the parameters in Section 3.5. Finding a preimage `x` such that `poseidon(NK_DOMAIN, x) == DUMMY_NK_HASH` is computationally infeasible. Used for dummy output slots. #### 3.5 Poseidon Hash Construction This EIP uses Poseidon over the BN254 scalar field (`p = 21888242871839275222246405745257275088548364400416034343698204186575808495617`) 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: per the Grassi–Khovratovich–Rechberger–Roy specification for BN254 at 128-bit security The primitive operation is `hash_2(a, b)`: a single Poseidon permutation with initial state `[a, b, 0]`, returning element 0 of the output state. For inputs of arity `n > 2`, a **left-balanced binary tree** composition is used: | Arity | Construction | |-------|-------------| | 2 | `hash_2(a, b)` | | 3 | `hash_2(hash_2(a, b), c)` | | 4 | `hash_2(hash_2(a, b), hash_2(c, d))` | | 5 | `hash_2(hash_2(hash_2(a, b), hash_2(c, d)), e)` | | 6 | `hash_2(hash_2(hash_2(a, b), hash_2(c, d)), hash_2(e, f))` | | 7 | `hash_2(hash_2(hash_2(a, b), hash_2(c, d)), hash_2(hash_2(e, f), g))` | | 9 | `hash_2(hash_2(hash_2(hash_2(a, b), hash_2(c, d)), hash_2(hash_2(e, f), hash_2(g, h))), i)` | The pattern generalizes: for any arity `n`, inputs are arranged into a left-balanced binary tree where each internal node applies `hash_2`. This ensures only a single Poseidon instantiation (T=3) is required across all circuit and on-chain contexts (matching `poseidon-solidity` and Noir's `poseidon::bn254::hash_2`). All `poseidon(...)` expressions in this EIP denote this binary-tree construction applied to the listed inputs in order. The following table lists each hash context and its input vector: | Context | Inputs (in order) | Arity | |---------|-------------------|-------| | Note commitment | `amount, ownerAddress, randomness, nullifierKeyHash, tokenAddress, label` | 6 | | Nullifier | `NULLIFIER_DOMAIN, nullifierKey, leafIndex_u32, randomness` | 4 | | Phantom nullifier | `PHANTOM_DOMAIN, nullifierKey, intentNullifier, slotIndex` | 4 | | Nullifier key hash | `NK_DOMAIN, nullifierKey` | 2 | | Output randomness | `RANDOMNESS_DOMAIN, nullifierKey, intentNullifier, slotIndex` | 4 | | Intent nullifier | `INTENT_DOMAIN, nullifierKey, intentChainId, nonce` | 4 | | Deposit label | `LABEL_DOMAIN, executionChainId, depositorAddress, tokenAddress, amount, intentNullifier` | 6 | | Label merge | `LABEL_MERGE_DOMAIN, min(labelA, labelB), max(labelA, labelB)` | 3 | | Merkle tree node | `left, right` | 2 | ### 4. Intent Transactions (Wallet Authorization) #### 4.1 Supported Format Only **EIP-1559 type-2** transactions (transaction type `0x02`) are valid intent transactions. The circuit MUST parse: ``` 0x02 || rlp([ chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, signatureYParity, r, s ]) ``` Constraints: * `accessList` MUST be empty. * `maxPriorityFeePerGas`, `maxFeePerGas`, and `gasLimit` are unconstrained by the circuit and MAY be set to any value. The Privacy RPC SHOULD return values that produce normal-looking transactions for the wallet (e.g., via `eth_gasPrice` and `eth_estimateGas` responses on the fictional chain). These fields are parsed during RLP decoding but do not affect proof validity. * `chainId` MUST equal `TRANSFER_CHAIN_ID(block.chainid)` for transfers or `WITHDRAWAL_CHAIN_ID(block.chainid)` for withdrawals. * Legacy (type-0) transactions MUST be rejected in v0. #### 4.2 Intent Semantics An intent tx is either: **ETH intent:** * `to` = recipient address * `value` = amount * `data` MUST be empty **ERC-20 intent:** * `to` = token contract address * `data` MUST be exactly an ABI-encoded `transfer(address,uint256)` call with selector `0xa9059cbb` * `value` MUST be 0 Any other calldata MUST be rejected. #### 4.3 Binding Operation Type The intent chain ID is included in the type-2 signed payload. Therefore, a valid signature on `TRANSFER_CHAIN_ID(E)` MUST NOT be accepted as authorization for a withdrawal, and vice versa. The RPC cannot convert a signed transfer into a withdrawal or vice versa because the chain ID is bound in the ECDSA signature. #### 4.4 Intent Expiry For transfers and withdrawals, the circuit MUST extract: ``` validUntilSeconds = nonce >> 32 ``` and expose it as a public input. The privacy pool contract MUST enforce: * If `validUntilSeconds > 0`: `block.timestamp <= validUntilSeconds` AND `validUntilSeconds <= block.timestamp + MAX_INTENT_LIFETIME`. The upper bound prevents intents with absurdly far-future expiry timestamps. This gives intents a bounded lifetime — a compromised or previously-trusted RPC cannot hold and execute a signed intent indefinitely. Users can also cancel a pending intent by waiting for it to expire. For deposits, `validUntilSeconds` MUST be 0 and the expiry check MUST be skipped. #### 4.5 Wallet Compatibility The wallet connects to the Privacy RPC as its RPC endpoint. It constructs a standard transfer, signs it, and hands it to the RPC. The RPC handles proof generation and on-chain submission. This works with MetaMask, hardware wallets, and any existing wallet without modification. Deposits require the wallet to be connected to Ethereum mainnet (real chain ID). Transfers and withdrawals require the wallet to be connected to the Privacy RPC's fictional chain ID network. Users switch networks in-wallet — standard UX, no special wallet behavior. The signing device is unchanged; the privacy client (Privacy RPC) is a separate component. "No privacy-aware wallet required" does not mean "no privacy-aware client." ### 5. Operation Modes The privacy pool supports three operation modes, determined by public inputs: #### 5.1 Deposit Mode (Public Deposit-to-Self) Deposit mode is selected when `depositorAddress != 0`. Requirements: * The pool tx sender MUST equal `depositorAddress` (`msg.sender == depositorAddress`). * `publicAmountIn > 0`. * `publicAmountOut == 0`. * `publicRecipient == 0`. * Both input slots MUST be phantom. * Output notes MUST be owned by `depositorAddress`. * `validUntilSeconds == 0`. Deposits are fully public with respect to token, amount, and depositor address. No intent transaction is needed for deposits. The Privacy RPC constructs the pool tx calldata (proof, public inputs, encrypted notes), and the wallet signs and submits the L1 transaction directly. Atomic deposit-to-third-party is out of scope for v0. #### 5.2 Transfer Mode (Shielded Transfer) Transfer mode is selected when: * `depositorAddress == 0` * Intent tx `chainId == TRANSFER_CHAIN_ID(block.chainid)` * `publicAmountIn == 0` * `publicAmountOut == 0` * `publicRecipient == 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 intent binds payment semantics (recipient, amount, token, operation type), not which notes are spent or which labels merge. #### 5.3 Withdrawal Mode (Public Withdrawal) Withdrawal mode is selected when: * `depositorAddress == 0` * Intent tx `chainId == WITHDRAWAL_CHAIN_ID(block.chainid)` * `publicAmountIn == 0` * `publicAmountOut > 0` * `publicRecipient != 0` * `publicTokenAddress` specifies the withdrawn token (`0` for ETH, otherwise ERC-20 address) Withdrawals are public with respect to token, amount, and recipient address. ### 6. Note Commitment and Nullifiers #### 6.1 Address and Amount Constraints Inside the circuit: * All address-valued fields (`ownerAddress`, `tokenAddress`, `depositorAddress`, `publicRecipient`) MUST be constrained to `< 2^160`. Without this, field aliasing could produce commitments or public inputs that pass proof verification but bind to different addresses than the EVM expects. * Amounts MUST be constrained to `< 2^128` in v0. ERC-20 amounts are `uint256`, but the SNARK field is ~254 bits. Capping at 128 bits avoids limb decomposition while covering all practical token amounts (including 18-decimal tokens up to ~3.4 × 10²⁰ units). #### 6.2 Note Commitment Notes MUST commit to at least: ``` commitment = poseidon( amount, ownerAddress, randomness, nullifierKeyHash, tokenAddress, label ) ``` * `ownerAddress` — 20-byte Ethereum address. Ties note ownership to existing identity. * `randomness` — blinding factor. Two notes with same amount/owner produce different commitments. * `nullifierKeyHash` — hash of the owner's nullifier key: `poseidon(NK_DOMAIN, nullifierKey)`. * `tokenAddress` — ERC-20 contract address, or `0` for ETH. * `label` — cryptographic lineage tag (see Section 7). The binary-tree Poseidon construction and exact input ordering are defined in Section 3.5. #### 6.3 Nullifier A real input note nullifier MUST be computed as: ``` nullifier = poseidon(NULLIFIER_DOMAIN, nullifierKey, leafIndex_u32, randomness) ``` * `nullifierKey` — secret known only to the note owner. * `leafIndex_u32` — position in the Merkle tree, as `u32` (not raw Field) to prevent index aliasing double-spends. * `randomness` — the note's blinding factor. #### 6.4 Phantom Nullifier If an input slot is phantom, the circuit MUST use: ``` phantom_nullifier = poseidon(PHANTOM_DOMAIN, nullifierKey, intentNullifier, slotIndex) ``` * `slotIndex` is 0 or 1 (the unused input slot). * `PHANTOM_DOMAIN` prevents collision with real nullifiers. * `nullifierKey` is the spender's secret — because it is private, an observer MUST NOT be able to distinguish phantom nullifiers from real ones. * `intentNullifier` (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. Labels and Lineage Every note MUST carry a `label` field — a Poseidon hash that traces the note's lineage back to the original deposit(s). Labels are enforced by the circuit; they cannot be forged. #### 7.1 Deposit Label In deposit mode, output labels MUST be derived from the deposit's public inputs: ``` label = poseidon( LABEL_DOMAIN, executionChainId, depositorAddress, tokenAddress, publicAmountIn, intentNullifier ) ``` `publicAmountIn` is the total public deposit amount, not individual output note amounts. `executionChainId` (= `block.chainid`) prevents cross-chain label collisions. `intentNullifier` is unique per transaction and known at proof generation time. Because all inputs are public, anyone can compute a deposit label. The deposit `intentNullifier` depends on a nonce chosen by the RPC rather than signed by the user. Deposit labels are therefore RPC-dependent — two RPCs serving the same deposit would produce different labels. ASPs MUST track deposit labels using the `intentNullifier` value from on-chain public inputs, not by predicting it. Uniqueness is enforced by the contract's intent nullifier set, not by a signature. #### 7.2 Transfer Label Propagation * **Single origin**: if both real input notes share the same label, output notes MUST inherit that label unchanged. * **Mixed origins**: if the two real input notes have different labels, output notes MUST use a commutative merge: ``` label = poseidon(LABEL_MERGE_DOMAIN, min(labelA, labelB), max(labelA, labelB)) ``` The merge is commutative — the same pair of labels always produces the same merged label regardless of which input slot they occupy. `LABEL_MERGE_DOMAIN` is domain-separated from `LABEL_DOMAIN`. A merge creates a new label — proof of innocence must handle the full label tree, not just a single hop. * **Phantom input**: if one input slot is phantom (`isPhantom == 1`), its label MUST be ignored. Output labels inherit the real input's label — no merge occurs. If both inputs are phantom (deposit mode), output labels are the freshly derived deposit label. The circuit MUST enforce these rules: phantom slots contribute no label to the merge logic. #### 7.3 Label Ancestry in Encrypted Payloads For mixed-origin notes, the encrypted note payload MUST include the two parent labels that were merged to produce the output label. The circuit MUST include parent labels in the encrypted note plaintext — they are bound by `encryptedNotesHash`. When a merge occurs, the plaintext MUST contain `(parentLabelA, parentLabelB)` sorted canonically; the circuit MUST verify these match the actual input labels used. For single-origin notes, the plaintext MUST include `(label, 0)`. A malicious sender cannot provide false ancestry because the circuit binds the parent labels to the ciphertext hash. Wallet software accumulates these into a local label DAG: each merged label maps to its two children, and deposit labels are leaves. #### 7.4 Ancestry Transfer at Spend Time When a sender transfers a merged-label note, the recipient receives only the immediate parent labels in the encrypted payload. For deep ancestry (parents that are themselves merges), the sender MUST provide the full label DAG to the recipient — either embedded in an extended ciphertext payload or via an out-of-band channel. Without the full DAG, the recipient cannot produce proof-of-innocence proofs for the note. Wallet software MUST track and forward complete ancestry when spending merged-label notes. In practice, most notes have shallow label DAGs. A user who deposits and transfers without mixing origins has a single deposit label (depth 0). A merge occurs only when two notes with different deposit lineages are spent together, producing depth 1. Repeated mixed-origin merges deepen the DAG, but counterparties requiring proof of innocence create market pressure to keep ancestry shallow — notes with deep or incomplete DAGs are harder to spend. Typical usage patterns (deposit, transfer, withdraw) produce DAGs of depth 0–2. A future version SHOULD replace hash-merge labels with an accumulator-based scheme (e.g., RSA or bilinear accumulators) where ancestry witnesses are constant-size and self-contained, eliminating the DAG transfer requirement entirely. ### 8. Registries #### 8.1 User Registry The privacy pool MUST maintain a Poseidon Merkle tree mapping: ``` address → (viewingPubKeyX, viewingPubKeyY, nullifierKeyHash) ``` The viewing public key is a Grumpkin curve point represented as two BN254 field elements `(viewingPubKeyX, viewingPubKeyY)`. The tree has its own root history buffer (size RECOMMENDED: >= 500). Registration is REQUIRED for depositors, spenders, and shielded-transfer recipients. Withdrawal recipients are the sole exception — they receive unshielded funds and need no registry entry. In practice, the Privacy RPC can register users automatically on first interaction, making this transparent. Sending to a completely new user requires them to register first — either directly or via the signature-authorized `registerFor` flow. Stealth addresses (future fork) would remove this requirement. #### 8.2 Registration Methods The contract MUST provide: * `register(viewingPubKeyX, viewingPubKeyY, nullifierKeyHash)` — callable by `msg.sender`. * `registerFor(address user, viewingPubKeyX, viewingPubKeyY, nullifierKeyHash, userNonce, signature)` — using an EIP-712 signature. The signature MUST commit to the address, viewing public key, nullifier key hash, and a per-user registration nonce. The contract MUST maintain a per-user `registrationNonce` that increments on each successful update and is included in the signed payload to prevent replay of old signatures. The EIP-712 domain is `{ name: "PrivacyPool", version: "1", chainId: block.chainid, verifyingContract: PRIVACY_POOL_ADDRESS }`. The typed struct is `Register(address user, uint256 viewingPubKeyX, uint256 viewingPubKeyY, uint256 nullifierKeyHash, uint256 nonce)`. The contract MUST verify the signature, require `nonce == registrationNonce[user]`, and increment `registrationNonce[user]` on success. #### 8.3 Key Rotation (v0) `nullifierKeyHash` MUST NOT be changed in v0 — it is embedded in every note's commitment, and rotating it would make existing notes unspendable. `viewingPubKey` MAY be updated. However, rotation invalidates any in-flight proofs that encrypted to the old key (they reference a now-stale registry root). Senders MUST retry with the new key. Because the registry uses a root history, rotated keys remain valid for senders until old roots expire. If a nullifier key is compromised, the user MUST register a new address and transfer funds — the viewing key can be rotated directly. If rotation is due to key compromise, incoming notes encrypted to the old key during the root-history window are visible to the attacker. A future version MAY add per-user epochs for cleaner staleness handling. #### 8.4 Issuer Registry The contract MUST maintain a Poseidon Merkle tree mapping: ``` tokenAddress → (issuerPubKey, issuerEpoch) ``` with its own root history buffer (size RECOMMENDED: >= 500). * `issuerEpoch == 0` indicates no issuer key registered (dummy ciphertext mode). * Key rotation increments `issuerEpoch`. The issuer authority for a given token is resolved by calling `privacyIssuerAuthority()` on the token contract, which MUST return an `address`. That address is the sole party authorized to register or rotate the issuer key for that token. If the call reverts or the token contract does not implement this function, no issuer key can be registered and the token operates in fully private (dummy ciphertext) mode. #### 8.5 Issuer Visibility Scope Issuer visibility is strictly scoped to the issuer's own token. A USDC issuer sees USDC movements; they see nothing about ETH, DAI, or any other token. Tokens without a registered issuer key (ETH, most DeFi tokens) produce dummy ciphertexts that no party can decrypt — fully private. ### 9. Circuit Requirements This EIP specifies a proof system (Circuit A) that verifies ECDSA authorization and registry membership. Implementations MAY use one or more circuits; all circuits MUST share the same public-input interface (Section 10). #### 9.1 Authorization The circuit MUST use `depositorAddress` (a public input) to determine the operation mode: **Deposit mode** (`depositorAddress != 0`): * ECDSA verification MUST be skipped. Authorization comes from `msg.sender` on-chain (the contract verifies `depositorAddress == msg.sender`). * Both input slots MUST be phantom. * `publicAmountIn > 0`. * `publicAmountOut == 0`. * Output notes MUST be owned by `depositorAddress`. **Transfer mode** (`depositorAddress == 0`, chain ID = `TRANSFER_CHAIN_ID(E)`): * The circuit MUST verify the ECDSA signature of the intent tx and recover the signer's Ethereum address. * The circuit MUST extract recipient, amount, and token from the intent tx structure: for ETH, `to` is the recipient and `value` is the amount; for ERC-20, `to` is the token address, and `transfer(recipient, amount)` is decoded from calldata. * `publicTokenAddress == 0`. * `publicAmountIn == 0`. * `publicAmountOut == 0`. * Recipient MUST match the output note owner. * `recipientNote.amount == intentAmount`. * `changeNote.amount == sum(inputs) - intentAmount` (or change note is dummy if exact). * Token from the intent tx MUST match all note token addresses (enforced privately). * The intent tx nonce MUST be verified consistent with `intentNullifier`. **Withdrawal mode** (`depositorAddress == 0`, chain ID = `WITHDRAWAL_CHAIN_ID(E)`): * Same ECDSA verification as transfer mode. * `publicAmountOut > 0`. * `publicAmountIn == 0`. * `publicRecipient == intentRecipient`. * `publicAmountOut == intentAmount`. * Token MUST match `publicTokenAddress`. The chain ID is committed in the ECDSA signature, so the RPC cannot swap a signed transfer intent for a withdrawal or vice versa. #### 9.2 Note Ownership and Membership For each input slot: * If `isPhantom == 0` (real input): the circuit MUST prove Merkle membership in `merkleRoot`. The commitment 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 `nullifier = poseidon(PHANTOM_DOMAIN, nullifierKey, intentNullifier, slotIndex)` and `amount = 0`. `isPhantom` MUST be constrained to 0 or 1. #### 9.3 Nullifier-Key Binding For real input slots, the circuit MUST enforce: ``` poseidon(NK_DOMAIN, nullifierKey) == note.nullifierKeyHash ``` This binds the nullifier key to the key hash committed in the note. For phantom input slots, the nullifier-key binding MUST be skipped. #### 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): the output commitment MUST be correctly formed for its owner and token. `nullifierKeyHash` MUST match the recipient's registry-proven key hash (recipient note) or the signer's own key hash (change note). * If `isDummy == 1` (dummy output): `amount` MUST equal 0 and the output MUST use `DUMMY_NK_HASH`. Even if a preimage for `DUMMY_NK_HASH` were found, the `amount == 0` constraint prevents minting — spending a dummy note contributes no value. Output note randomness MUST be deterministically derived: ``` randomness = poseidon(RANDOMNESS_DOMAIN, nullifierKey, intentNullifier, slotIndex) ``` This ensures a single valid set of output commitments exists per intent, removing RPC discretion over which commitment lands in the tree. The RPC cannot produce alternative valid proofs for the same signed intent with different output commitments. #### 9.6 Registry Binding Gated by operation type: * **Transfer**: the circuit MUST prove the recipient address has a registry entry (for output note encryption and `nullifierKeyHash`). The circuit MUST also prove the sender (ECDSA signer) has a registry entry, extracting the sender's viewing public key for change note encryption. * **Withdrawal**: the circuit MUST prove the sender has a registry entry, extracting the sender's viewing public key for output note encryption. Recipient binding is skipped — the recipient receives unshielded funds via `publicRecipient`. Any address can be a withdrawal destination; compliance is handled by proof of innocence at the counterparty level, not by registry membership. * **Deposit**: the circuit MUST prove `depositorAddress` has a registry entry, extracting the depositor's viewing public key and nullifier key hash for output note encryption and `nullifierKeyHash`. #### 9.7 Encryption Correctness Gated by operation type: * **Transfer**: encrypts the recipient note to the recipient's registry-proven key and the change note to the sender's key. Checks against `encryptedNotesHash`. * **Withdrawal**: only the change note (if any) is a real output — it is encrypted to the sender's registry-proven key. If the entire input value is withdrawn (`publicAmountOut` equals total input), both output slots are dummy. Checks against `encryptedNotesHash`. * **Deposit**: encrypts output notes to the depositor's registry-proven key. Checks against `encryptedNotesHash`. #### 9.7.1 Encryption Scheme Note encryption uses ECDH over the Grumpkin embedded curve with a Poseidon-based KDF. The plaintext per note is: `(amount, ownerAddress, randomness, tokenAddress, label, parentLabelA, parentLabelB)` — 7 field elements. For single-origin notes, `parentLabelA = label` and `parentLabelB = 0`. This ensures all ciphertexts are the same size regardless of merge history and binds parent labels into the ciphertext per Section 7.3. **Recipient notes (ECDH mode):** 1. Derive encryption seed: `encSeed = poseidon(nullifierKey, ENC_KEY_DOMAIN)`. 2. Derive ephemeral scalar deterministically: `eph = poseidon(encSeed, recipientPubKey.x, recipientPubKey.y, intentNullifier, slotIndex, randomness, EPHEMERAL_DOMAIN)`. 3. Compute ephemeral public key: `E = G * eph` (Grumpkin fixed-base scalar multiplication). 4. Compute shared secret: `ss = poseidon(ECDH(eph, recipientPubKey).x, SHARED_DOMAIN)`. 5. Derive keystream: `key_i = poseidon(ss, i, KEYSTREAM_DOMAIN)` for `i` in `{0, 1, ..., 6}`. 6. Encrypt by field addition: `enc_field_i = plaintext_i + key_i` for each of the 7 plaintext fields. Ciphertext: `(E.x, E.y, enc[0], enc[1], enc[2], enc[3], enc[4], enc[5], enc[6])` — 9 field elements per note. **Change notes (self-encryption mode):** 1. Derive self-scalar: `selfScalar = poseidon(encSeed, intentNullifier, SELF_EPHEMERAL_DOMAIN)`. 2. Compute self-ephemeral: `S = G * selfScalar`. 3. Derive self-secret: `selfSecret = poseidon(encSeed, S.x, SELF_SECRET_DOMAIN)`. 4. Keystream and encryption proceed as in ECDH mode (7 keystream elements, same plaintext layout). Self-encryption does not use the recipient's public key (the recipient is the sender). The self-ephemeral point is included in the ciphertext for auditability. Ciphertext format is identical: 9 field elements per note. **Ciphertext hash:** `encryptedNotesHash = poseidon(hash_9(ciphertext_0), hash_9(ciphertext_1))`, where `hash_9` is the binary-tree Poseidon over the 9 ciphertext field elements per note. The contract deserializes the submitted `encryptedNoteData` into field elements and recomputes this Poseidon hash via the precompile to verify it matches the `encryptedNotesHash` public input. **Determinism:** All encryption randomness is derived from `nullifierKey` and `intentNullifier`, ensuring a single valid ciphertext per intent. This eliminates covert channels through which the RPC could embed data in ciphertexts. `ENC_KEY_DOMAIN`, `EPHEMERAL_DOMAIN`, `KEYSTREAM_DOMAIN`, `SHARED_DOMAIN`, `SELF_EPHEMERAL_DOMAIN`, and `SELF_SECRET_DOMAIN` are encryption-specific domain separators derived using the same procedure as Section 3.3: * `ENC_KEY_DOMAIN` — `keccak256("privacy_pool.enc_key") mod p` * `EPHEMERAL_DOMAIN` — `keccak256("privacy_pool.ephemeral") mod p` * `KEYSTREAM_DOMAIN` — `keccak256("privacy_pool.keystream") mod p` * `SHARED_DOMAIN` — `keccak256("privacy_pool.shared") mod p` * `SELF_EPHEMERAL_DOMAIN` — `keccak256("privacy_pool.self_ephemeral") mod p` * `SELF_SECRET_DOMAIN` — `keccak256("privacy_pool.self_secret") mod p` #### 9.8 Intent Nullifier The circuit MUST enforce: ``` intentNullifier = poseidon(INTENT_DOMAIN, nullifierKey, intentChainId, nonce) ``` * In transfer/withdrawal modes, `intentChainId` and `nonce` MUST be parsed from the intent tx. `intentChainId` is the chain ID field from the signed type-2 transaction (i.e., `TRANSFER_CHAIN_ID(E)` or `WITHDRAWAL_CHAIN_ID(E)`). The circuit MUST also extract `validUntilSeconds` from the upper 32 bits of the nonce and expose it as a public input. * In deposit mode, `intentChainId` MUST be the execution chain ID (`block.chainid`), and `nonce` is a private random value provided by the RPC. `validUntilSeconds` MUST be 0. #### 9.9 Label Propagation The circuit MUST enforce output labels are correctly derived from input labels per the rules in Section 7: * Deposit: label derived from public inputs per Section 7.1. * Single-origin transfer: label inherited unchanged. * Mixed-origin transfer: commutative merge per Section 7.2. * Phantom inputs: label ignored per Section 7.2. The circuit MUST include parent labels in the encrypted note plaintext, bound by `encryptedNotesHash`, per Section 7.3. #### 9.10 Token Consistency All 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 Issuer Key Check The circuit MUST prove `(tokenAddress, issuerPubKey, issuerEpoch)` membership in the issuer registry against `issuerRegistryRoot` for all operations. * If `issuerEpoch > 0`: encrypt the issuer plaintext to `issuerPubKey` as specified below. Checks against `issuerCiphertextHash`. * If `issuerEpoch == 0`: produce a dummy ciphertext. Checks against `issuerCiphertextHash`. **Issuer ciphertext layout.** The issuer plaintext per note is: `(amount, ownerAddress, randomness, tokenAddress, label)` — 5 field elements. Encryption uses the same ECDH/Poseidon-KDF scheme as user-facing encryption (Section 9.7.1) but with the issuer's registry-proven public key as the recipient key. The ephemeral scalar MUST incorporate `issuerEpoch` to bind the ciphertext to the current key epoch: ``` issuerEph = poseidon(encSeed, issuerPubKey.x, issuerPubKey.y, issuerEpoch, intentNullifier, slotIndex, ISSUER_EPHEMERAL_DOMAIN) ``` Keystream: `key_i = poseidon(issuerSharedSecret, i, KEYSTREAM_DOMAIN)` for `i` in `{0, ..., 4}`. Ciphertext per note: `(IE.x, IE.y, enc[0], enc[1], enc[2], enc[3], enc[4])` — 7 field elements. `issuerCiphertextHash = poseidon(hash_7(issuerCiphertext_0), hash_7(issuerCiphertext_1))`, where `hash_7` is the binary-tree Poseidon over the 7 ciphertext field elements per note. The contract deserializes `issuerCiphertextData` into 14 field elements and recomputes this hash via the Poseidon precompile. **Dummy ciphertexts.** When `issuerEpoch == 0`, the circuit MUST produce a dummy ciphertext of the same size (7 field elements per note, 14 total). Dummy ciphertext values MUST be deterministically derived from `nullifierKey` and `intentNullifier` (e.g., `dummy_i = poseidon(nullifierKey, intentNullifier, i, ISSUER_DUMMY_DOMAIN)`). Without the decryption key, observers MUST NOT be able to distinguish real from dummy. `issuerCiphertextHash` MUST always be nonzero. `ISSUER_EPHEMERAL_DOMAIN` and `ISSUER_DUMMY_DOMAIN` are encryption-specific domain separators derived using the same procedure as Section 3.3: * `ISSUER_EPHEMERAL_DOMAIN` — `keccak256("privacy_pool.issuer_ephemeral") mod p` * `ISSUER_DUMMY_DOMAIN` — `keccak256("privacy_pool.issuer_dummy") mod p` ### 10. Public Inputs All proofs verified by the pool MUST share the following public-input vector: ``` merkleRoot // commitment tree root the proof is against nullifier0 // first input note nullifier nullifier1 // second input note nullifier (phantom if unused) commitment0 // new note (recipient or self) commitment1 // new note (change to sender, or dummy if unused) publicAmountIn // tokens entering the shielded state (deposit), 0 otherwise publicAmountOut // tokens leaving the shielded state (withdrawal), 0 otherwise publicRecipient // withdrawal destination address, 0 otherwise publicTokenAddress // token being transacted (0 for ETH); 0 for transfers depositorAddress // depositor's Ethereum address (deposit), 0 for transfers/withdrawals encryptedNotesHash // hash of encrypted note ciphertexts intentNullifier // replay protection registryRoot // root of user registry (always nonzero) issuerRegistryRoot // root of issuer key registry (always nonzero) validUntilSeconds // intent expiry timestamp (transfers/withdrawals); 0 for deposits issuerCiphertextHash // hash of issuer ciphertext (always nonzero; dummy if no issuer key) executionChainId // block.chainid of the target execution chain ``` `publicAmountIn` and `publicAmountOut` apply to the token specified by `publicTokenAddress`. For transfers, all three are zero. `registryRoot` and `issuerRegistryRoot` MUST always be nonzero. `executionChainId` is verified by the contract against `block.chainid` (Section 12.4, step 2). This provides defense-in-depth against cross-chain proof replay, complementing the per-chain intent chain IDs. #### 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. Precompiles #### 11.1 Poseidon Hashing (REQUIRED) A Poseidon hashing precompile MUST be provided to make Merkle updates feasible at scale. The contract updates a depth-32 Poseidon Merkle tree on every call (~32 hashes per insertion). On-chain Poseidon in Solidity is not gas-feasible at scale. * Address: `POSEIDON_PRECOMPILE_ADDRESS` (TBD) * Interface: MUST accept BN254 T3 (2-arity) Poseidon inputs and return a single field element, using the parameters specified in Section 3.5. EIP-5988 defines a parameterizable Poseidon precompile that MAY satisfy this requirement; if EIP-5988 is adopted, this EIP's Poseidon precompile MAY be implemented via EIP-5988 with the appropriate BN254 T3 parameters. #### 11.2 Proof Verification (REQUIRED for canonical pool) A proof verification precompile MUST verify proofs for the circuit family identified by `circuitId`. The precompile routes to the correct verification key based on the circuit identifier. * Address: `PROOF_VERIFY_PRECOMPILE_ADDRESS` (TBD) * Input: `(circuitId, proofBytes, publicInputs)` * Output: success/failure A Solidity verifier fallback is possible for the canonical contract, but a precompile lets other pools and wrapper contracts verify proofs from this scheme without deploying their own verifiers or tracking circuit upgrades. ### 12. Privacy Pool System Contract #### 12.1 Deployment and Upgrade Model The privacy pool is deployed as a system contract at `PRIVACY_POOL_ADDRESS` (TBD), following the pattern established by EIP-4788 (beacon block root), EIP-2935 (historical block hashes), EIP-7002 (execution layer exits), and EIP-7251 (consolidations). * The contract is deployed via Nick's method (a pre-signed transaction from a single-use deployer account) for a deterministic, cross-chain address. * The code at `PRIVACY_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; implementations MUST maintain storage layout compatibility (see Section 12.2). Reference: [`ethereum/sys-asm`](https://github.com/ethereum/sys-asm) for the system contract toolchain used by EIP-4788 and related EIPs. **No emergency upgrade path.** A critical bug in the contract requires either a hard fork or social coordination to migrate to a new contract. This is the price of credible neutrality. There is no admin key that could be used to patch the contract, and that is by design. #### 12.2 State The pool MUST maintain: * **Commitment Merkle tree** — append-only Poseidon Merkle tree (depth RECOMMENDED: 32, ~4B leaves). Empty leaf = 0. Holds multi-asset notes (`tokenAddress` is inside the commitment). * **Commitment root history** — circular buffer of recent roots (size RECOMMENDED: >= 500) so proofs against slightly stale roots remain valid. * **Nullifier set** — `mapping(uint256 => bool)`. * **Intent nullifier set** — `mapping(uint256 => bool)`. * **User registry** — Poseidon Merkle tree mapping `address → (viewingPubKey, nullifierKeyHash)`, with its own root history. * **Issuer registry** — Poseidon Merkle tree mapping `tokenAddress → (issuerPubKey, issuerEpoch)`, with its own root history. Implementations MUST maintain storage layout compatibility across hard-fork code replacements. The commitment tree root, next leaf index, root history buffer, nullifier mappings, intent nullifier mapping, and registry trees MUST occupy stable storage slots. A hard fork that replaces the contract code MUST NOT alter or reinterpret existing storage. Apps that need custom policy can deploy wrapper contracts on top, but all calls operate against this single canonical contract state. #### 12.3 Contract Interface The pool MUST expose the following functions: **Pool transaction:** ```solidity function transact( bytes calldata proof, uint256 circuitId, uint256[17] calldata publicInputs, bytes calldata encryptedNoteData, bytes calldata issuerCiphertextData ) external payable ``` `publicInputs` is a fixed-size array of 17 `uint256` values in the order defined by Section 10: `[merkleRoot, nullifier0, nullifier1, commitment0, commitment1, publicAmountIn, publicAmountOut, publicRecipient, publicTokenAddress, depositorAddress, encryptedNotesHash, intentNullifier, registryRoot, issuerRegistryRoot, validUntilSeconds, issuerCiphertextHash, executionChainId]`. **User registration:** ```solidity function register( uint256 viewingPubKeyX, uint256 viewingPubKeyY, uint256 nullifierKeyHash ) external function registerFor( address user, uint256 viewingPubKeyX, uint256 viewingPubKeyY, uint256 nullifierKeyHash, uint256 userNonce, bytes calldata signature ) external ``` `register` is called by `msg.sender` to bind their address to a viewing public key and nullifier key hash. `registerFor` allows a third party to register on behalf of `user` using an EIP-712 signature (see Section 8.2). #### 12.4 Execution On each call, the pool MUST execute the following steps: 1. **Verify the proof** via the verification precompile using `circuitId`, `proof`, and `publicInputs`. 2. **Verify execution chain ID.** Require `executionChainId == block.chainid`. 3. **Enforce intent expiry.** If `validUntilSeconds > 0`: * Require `block.timestamp <= validUntilSeconds`. * Require `validUntilSeconds <= block.timestamp + MAX_INTENT_LIFETIME`. 4. **Check merkle root.** Require `merkleRoot` is in the commitment root history. 5. **Check registry root.** Require `registryRoot` is in the user registry root history. `registryRoot` MUST be nonzero. 6. **Check issuer registry root.** Require `issuerRegistryRoot` is in the issuer registry root history. `issuerRegistryRoot` MUST be nonzero. 7. **Enforce nullifier uniqueness.** Require `nullifier0 != nullifier1` (defense-in-depth; the circuit guarantees this — real nullifiers use distinct `leafIndex` values; phantom nullifiers use distinct `slotIndex` values). 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 intent nullifier used.** Require `intentNullifier` is unused; then mark it used. 10. **Insert commitments.** Insert `commitment0` and `commitment1` into the Merkle tree. Commitments MUST be nonzero — dummy outputs use nonzero dummy commitments (inserting 0 is indistinguishable from the tree's empty leaf value). 11. **Verify encrypted note data.** `encryptedNoteData` is 576 bytes: each field element is encoded as a 32-byte big-endian `uint256`, concatenated in order — `ciphertext_0[0..8]` then `ciphertext_1[0..8]` (9 elements per note, 18 total). Recompute `poseidon(hash_9(ciphertext_0), hash_9(ciphertext_1))` via the Poseidon precompile and require the result equals `encryptedNotesHash`. Each 32-byte element MUST be `< p` (BN254 scalar field modulus); the contract MUST reject the transaction if any ciphertext element is non-canonical. 12. **Verify issuer ciphertext data.** Require `issuerCiphertextHash != 0`. `issuerCiphertextData` is 448 bytes: each field element is encoded as a 32-byte big-endian `uint256`, concatenated in order — `issuerCiphertext_0[0..6]` then `issuerCiphertext_1[0..6]` (7 elements per note, 14 total). Recompute the Poseidon hash via precompile and require the result equals `issuerCiphertextHash`. Each 32-byte element MUST be `< p` (BN254 scalar field modulus); the contract MUST reject the transaction if any ciphertext element is non-canonical. 13. **Execute asset movement based on operation mode:** **Deposit** (`depositorAddress != 0`): * Require `msg.sender == depositorAddress`. * Require `publicAmountIn > 0` (redundant with circuit rule 9.1, but defense-in-depth). * 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. Exact approvals are RECOMMENDED — large standing approvals expose users to excess deposit risk if the RPC is compromised. **Transfer or withdrawal** (`depositorAddress == 0`): * Require `publicAmountIn == 0`. If `publicAmountIn > 0`, the call MUST be rejected — transfers and withdrawals MUST NOT have `publicAmountIn`. * The on-chain tx submitter MAY be a relayer whose address is irrelevant to the proof — only the intent tx signer matters. **Withdrawal** (`publicAmountOut > 0`): * If `publicTokenAddress == 0` (ETH): send `publicAmountOut` to `publicRecipient`. * If `publicTokenAddress != 0` (ERC-20): execute `transfer(publicRecipient, publicAmountOut)` and require success. Require `msg.value == 0`. All ERC-20 calls (`transferFrom`, `transfer`, `balanceOf`) MUST use safe call semantics: if the call returns empty data, treat it as success; if it returns 32 bytes of ABI-encoded `true`, treat it as success; any other return data or a revert MUST cause the transaction to revert. The deposit-side balance-delta check enforces that tokens with transfer fees or rebasing behavior revert on deposit, preventing conservation-equation violations. If any ERC-20 call reverts, returns false, or the delta check fails, the transaction MUST revert and the token is unsupported. No withdrawal-side balance check is performed: the pool calls `transfer` with the correct amount and its obligation ends there. A token that charges fees only on outbound `transfer` (not on `transferFrom`) would pass the deposit check but deliver less than `publicAmountOut` to the withdrawal recipient. The pool's conservation accounting remains correct — it debits the full amount — but the recipient receives less than expected. Such tokens are not compatible with this system; the pool does not attempt to detect or compensate for outbound transfer fees. 14. **Emit events.** Emit the following event: ```solidity event PrivacyPoolTransact( uint256 indexed nullifier0, uint256 indexed nullifier1, uint256 indexed intentNullifier, uint256 commitment0, uint256 commitment1, uint256 merkleRoot, bytes encryptedNoteData, bytes issuerCiphertextData ); ``` Nullifiers and `intentNullifier` are indexed for efficient scanning and lookup. Commitments and `merkleRoot` are non-indexed (scanners decrypt ciphertexts, not search by commitment). Ciphertext bytes are non-indexed (too large for topic slots). Ciphertext MUST NOT be written to contract storage — scanners read events or calldata. ### 13. Proof of Innocence Users can prove their funds descend from deposits not associated with sanctioned addresses, without revealing which deposits are theirs. Proof of innocence is NOT enforced by the base contract — it is a separate proof verified by counterparties. #### 13.1 Association Set Providers ASPs publish Merkle roots over sets of deposit labels they consider clean. Anyone can run an ASP. Different counterparties can trust different ASPs. #### 13.2 The Proof A user proves their note's label ancestry resolves entirely to deposit labels that are members of the ASP's clean set: * **Single-origin notes** (label inherited from one deposit): a simple Merkle membership proof against the ASP root. * **Mixed-origin notes** (label derived from merging): the user MUST prove membership for every leaf in their label tree — every original deposit that contributed to the note's lineage. The wallet traverses from the note's label down to deposit leaves and proves membership for each. #### 13.3 Binding to the Spend The PoI proof binds to the spend via nullifiers, which are public inputs of the spend transaction. The PoI prover (the note owner) knows the full note opening, including the label. The PoI circuit re-proves that a note producing the given nullifier exists in the commitment tree, extracts its label, and proves that label's ancestry resolves to clean deposits in the ASP's set. No additional public inputs are needed on the spend proof — the nullifier uniquely identifies the spent note and serves as the binding point. This prevents replay: a PoI proof is only valid for the specific nullifier it was generated against. #### 13.4 Multi-Input Spends The PoI verifier MUST require a PoI proof for both nullifiers — it cannot distinguish phantom from real (by design). The PoI circuit handles each nullifier in one of two modes: * **Real mode**: proves a note producing that nullifier exists in the commitment tree, extracts its label, and proves the label's ancestry resolves to clean deposits in the ASP's set. * **Phantom mode**: proves the nullifier matches the phantom formula `poseidon(PHANTOM_DOMAIN, nullifierKey, intentNullifier, slotIndex)`, without revealing which mode was used. The PoI proof MUST NOT leak whether an input is phantom. #### 13.5 Limitations (v0) PoI in v0 is best-effort: notes with incomplete ancestry (e.g., from a sender who withheld the label DAG) simply cannot produce PoI proofs. Counterparties requiring PoI MAY reject such notes. A future accumulator-based scheme (see Section 7.4) SHOULD make ancestry self-contained and eliminate this limitation. ### 14. Threat Model The system supports two proving modes. In **local proving** mode, the user generates proofs on their own device and submits pool transactions through any relayer. In **RPC proving** mode, the user delegates proof generation to a Privacy RPC, which learns transfer details but cannot steal funds or forge proofs. | | Chain observer | Relayer | Privacy RPC | Local proving | |---|---|---|---|---| | Tx occurs | yes | yes | yes | yes | | Token | no | no | yes | no | | Amount | no | no | yes | no | | Sender | no | no | yes | no | | Recipient | no | no | yes | no | | Which notes spent | no | no | yes | no | | Note balances | no | no | yes | no | The "no" entries for amount, sender, recipient, and token apply to **shielded transfers only**. Deposits are fully public: depositor address, amount, and token are all visible on-chain. Withdrawals expose `publicAmountOut`, `publicRecipient`, and token on-chain. This is by design — deposits pull funds from `msg.sender` and withdrawals push funds to a named address; both require public visibility. **Local proving** provides full privacy: no party other than the user learns transfer details. The proof is generated locally and submitted via any relayer. The relayer sees only the on-chain public inputs — the same as any chain observer. **RPC proving** trades metadata privacy for convenience. The RPC learns transfer details (token, amount, sender, recipient, note selection) but cannot: steal funds (the intent signature authorizes only the user's specified operation), forge proofs for unauthorized operations, redirect payments, or produce alternative valid proofs for the same intent (deterministic outputs and encryption). RPC proving reduces the audience from the entire world to a single party — the RPC — and works with every existing wallet without modification. ### 15. App-Level Extensions Apps MAY deploy wrapper contracts that impose additional requirements before or after a privacy pool interaction: * **Proof of innocence**: a receiving contract or counterparty verifies a PoI proof against an ASP root before accepting a withdrawal. * **Compliance**: wrapper contracts can enforce KYC gating, fee collection, or other restrictions at the withdrawal destination. * **Custom policy**: any logic a contract wants to run conditional on a privacy pool call completing. These extensions operate on top of the canonical contract without fragmenting the anonymity set. ### 16. Future Auth Methods Each new auth method is a new circuit with the same public-input interface. Adding a circuit requires replacing the system contract code via hard fork. * **Circuit C — P-256 (passkeys/Face ID):** Same as Circuit A but verifies P-256 instead of secp256k1 ECDSA. A different intent format (e.g., EIP-712 typed data signed with P-256, or a WebAuthn assertion) would be required. * **Circuit D — Post-quantum:** Same interface, different signature verification. All circuits share the same note tree, same notes, same anonymity set. ### 17. Future: Stealth Addresses The registry can be upgraded from static viewing keys to ERC-5564 style meta-addresses. The sender derives a one-time key per transaction from the meta-address. This requires a new circuit (same interface) that verifies the stealth derivation instead of a simple registry lookup. Better recipient privacy, same note format, same storage layout, same public inputs. Added by hard fork. ### 18. Chain Specifics * Each network derives its own intent chain IDs from `block.chainid` per the formula in Section 3.1. Testnets, devnets, and L2s each produce distinct intent chain IDs automatically. * The system contract and precompiles MUST be deployed at the same addresses across all networks, unless explicitly overridden by the network's genesis specification. * Clients MUST reject intents whose parsed intent chain ID does not match the expected `TRANSFER_CHAIN_ID(block.chainid)` or `WITHDRAWAL_CHAIN_ID(block.chainid)` for the current execution chain and operation type. * Clients MUST treat the system contract address as reserved. ## Rationale * **Reserved intent chain IDs** allow unmodified wallets (including hardware wallets) to sign standard transactions while preventing replay on a real chain, and binding operation type into the signature. Per-chain derivation via `keccak256` ensures intent chain IDs are unique across all execution chains without coordination. * **Type-2 transaction format over EIP-712** trades circuit efficiency (RLP parsing and keccak are significant constraint contributors) for immediate wallet compatibility. EIP-712 typed-data signing would reduce circuit complexity but is not uniformly supported across hardware wallets and signing stacks. The type-2 format works with every existing wallet today and requires no ecosystem coordination. If wallets converge on a privacy-native signing interface in the future, a lighter circuit (same public-input interface) can be added by hard fork. * **Packed nonce with expiry and upper bound** prevents both indefinite "hanging" execution of previously signed intents by a compromised RPC/relayer and absurdly far-future expiry timestamps. * **Two-input, two-output shape with phantom/dummy slots** reduces metadata leakage and enables constant-size on-chain data structures. Observers cannot distinguish one-input from two-input transactions. * **Protocol-enshrined upgrade path** replaces admin/governance risk with the same social-consensus guarantees as any other protocol change. No admin key, no governance token, no multisig. * **Single canonical note tree** across all tokens ensures the transfer anonymity set spans all assets. Token consistency is enforced privately in the circuit for transfers. * **Deterministic output commitments and encryption** eliminate RPC discretion over proof outputs and close covert channels in ciphertexts. * **Label-based lineage** enables proof of innocence without requiring a global compliance layer — counterparties choose their own policy. * **Issuer viewing keys** make permissioned-asset issuers stakeholders rather than adversaries, scoped strictly to their own token. * **Deposit-to-self restriction** simplifies the v0 design by avoiding the need for intent transactions on deposits. Future versions may add atomic deposit-and-transfer. ## 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 The following conformance tests MUST be provided in client test suites before activation (illustrative, non-exhaustive): 1. **Intent parsing** * A valid type-2 ETH intent on `TRANSFER_CHAIN_ID(block.chainid)` is accepted; same payload on any other chain ID is rejected. * A valid type-2 ERC-20 intent with selector `0xa9059cbb` is accepted; any other selector is rejected. * A type-0 (legacy) intent is rejected. * An intent with non-empty `accessList` is rejected. 2. **Expiry** * A transfer with `validUntilSeconds < block.timestamp` is rejected. * A transfer with `validUntilSeconds > block.timestamp + MAX_INTENT_LIFETIME` is rejected. * A deposit with `validUntilSeconds == 0` bypasses the expiry check. 3. **Nullifier uniqueness** * Re-submitting a pool tx with any already-spent nullifier is rejected. * A pool tx where `nullifier0 == nullifier1` is rejected. 4. **Intent uniqueness** * Re-submitting a pool tx with an already-used `intentNullifier` is rejected. 5. **Deposit authorization** * Deposit mode call where `msg.sender != depositorAddress` is rejected. * Deposit mode call with `publicAmountIn == 0` is rejected. 6. **Transfer/withdrawal input rejection** * A transfer or withdrawal (`depositorAddress == 0`) with `publicAmountIn > 0` is rejected. 7. **Asset movement** * ETH deposits require `msg.value == publicAmountIn`. * ERC-20 deposits require `msg.value == 0`, `transferFrom` success, and `balanceOf(pool)` delta equals `publicAmountIn`. * ERC-20 deposit of a fee-on-transfer token reverts (balance delta mismatch). * ETH withdrawals send correct amount to `publicRecipient`. * ERC-20 withdrawals transfer correct amount to `publicRecipient` with `msg.value == 0`. 8. **Commitment insertion** * Zero-valued commitments are rejected (indistinguishable from empty leaf). 9. **Root history** * A proof against a valid root within the history buffer is accepted. * A proof against an expired root outside the history buffer is rejected. 10. **Canonical field elements** * A public input `>= p` (SNARK field modulus) is rejected. 11. **Ciphertext binding** * `encryptedNoteData` whose hash does not match `encryptedNotesHash` is rejected. * `issuerCiphertextData` whose hash does not match `issuerCiphertextHash` is rejected. * `issuerCiphertextHash == 0` is rejected. 12. **Execution chain ID** * A proof with `executionChainId != block.chainid` is rejected. 13. **Registry** * A user can register via `msg.sender`. * A user can register via EIP-712 signature with correct nonce. * Replayed registration signatures (stale nonce) are rejected. Concrete test vectors (exact bytes) are TBD pending finalization of proof system encoding and ABI/event formats. ## Reference Implementation TBD. A minimal reference implementation SHOULD include: * Solidity/Yul code for the system contract interface (excluding cryptographic internals). * Client-side state transition logic for deployment and hard-fork code replacement. * Precompile stubs and test harnesses. * A reference prover/verifier integration for at least one circuit identifier (Circuit A — ECDSA). * A reference Privacy RPC implementation demonstrating the intent-transaction flow. ## Security Considerations ### Intent Replay on Real Chains Intent chain IDs are derived from `block.chainid` via keccak256, making collisions with real chain IDs negligible. Per-chain derivation also prevents cross-chain intent replay between different privacy pool deployments. The `executionChainId` public input provides an additional contract-level check against `block.chainid`. Implementers SHOULD include wallet-side UI labeling that clearly indicates "Privacy Intent Network" to reduce phishing risk. ### Malicious RPC/Relayer Expiry-limited intents (with `MAX_INTENT_LIFETIME` upper bound) reduce indefinite execution risk. Deterministic output commitments and deterministic encryption randomness eliminate covert channels — the RPC cannot produce alternative valid proofs for the same signed intent with different output commitments or embed data in ciphertexts. However, in "RPC proving" mode, the RPC learns token, amount, sender, recipient, which notes are spent, and note balances. Users requiring privacy from the RPC MUST use local proving (expert mode). ### Key Compromise `nullifierKeyHash` immutability implies compromised nullifier keys require migration to a new address and transferring all funds. Viewing keys may be rotated, but senders proving against stale registry roots may need to retry. During the root-history window after a viewing-key rotation due to compromise, incoming notes encrypted to the old key are visible to the attacker. ### Front-Running / Fee Theft If any relayer compensation mechanism is added, the fee recipient MUST be bound inside the proof (or the fee must be fully public) to avoid mempool front-running where another relayer steals the pending transaction and collects the fee. The relayer fee mechanism is deferred and MUST be specified before relayer support is considered production-ready. ### DoS via Root History Limited root histories allow bounded proof staleness but can cause proof failures under prolonged congestion. Root history sizes SHOULD be chosen conservatively (>= 500). ### Precompile Correctness Poseidon and verifier precompiles are consensus-critical. Any bugs are chain-splitting risks. Implementations MUST be extensively tested and audited. ### Token Incompatibility Fee-on-transfer and rebasing tokens violate the conservation equation and MUST NOT be deposited. The deposit-side balance-delta check (Section 12.4, step 13) enforces this by reverting if the pool's actual balance increase differs from `publicAmountIn`. Tokens that charge fees only on outbound `transfer` (not `transferFrom`) would pass the deposit check but deliver less than `publicAmountOut` to the withdrawal recipient. Such tokens are not compatible with this system; the pool does not attempt to detect or compensate for outbound transfer fees. ### 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/2-output shape with phantom/dummy slots mitigates some structural metadata leakage. ### State Growth Each transaction writes ~4 permanent storage slots (2 nullifiers, 1 intent nullifier, root history entry) plus 2 tree insertions. Nullifiers are not pruneable. This is standard for privacy pools but enshrinement makes it protocol-level state. Future mitigation via stateless clients or accumulator-based nullifier sets. ### Encryption Authentication The ECDH/Poseidon-KDF encryption scheme used for note and issuer ciphertexts is not standalone-authenticated. Ciphertext integrity depends on the ZK proof binding ciphertexts to `encryptedNotesHash` and `issuerCiphertextHash` — without a valid proof, a ciphertext could be replaced or tampered with. The encryption scheme provides IND-CPA-level confidentiality; authenticity is provided by the proof system, not the encryption primitive. ### Deposit Approval Risk ERC-20 deposits require prior `approve` to the contract address. Exact approvals are RECOMMENDED. Large standing approvals expose users to excess deposit risk if the RPC constructs an unauthorized deposit transaction. The wallet signs and submits deposit pool transactions directly, so a compromised RPC that constructs a larger-than-intended deposit calldata could drain the approved amount. ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).