This EIP introduces protocol-level private ETH and ERC-20 transfers with public deposits and withdrawals, implemented as a fixed-address system contract with a companion proof-verification precompile. A recursive proof architecture separates protocol invariants enforced by a hard-fork-managed outer circuit from permissionless inner authentication circuits, allowing users to choose compatible authentication methods — such as ECDSA, passkeys, or multisig — without requiring a hard fork for each new auth method. The system contract has no on-chain upgrade mechanism and can only be replaced by a hard fork.
Ethereum transactions and balances are public by default. This deters real demand from users and organizations that require basic financial privacy: payroll, treasury operations, institutional flows, and ordinary payments.
Ethereum has no canonical private transfer system. Private transfers are possible through app-layer pools, but multiple incompatible deployments coexist and none has emerged as the default target for wallet and infrastructure integration. This fragmentation hurts adoption and it hurts privacy, because splitting users across pools shrinks the anonymity set that each pool provides.
This EIP defines a canonical pool — one system contract at a fixed address — that the ecosystem can build against instead of choosing among incompatible pools.
Protocol enshrinement also resolves an upgradeability dilemma. An upgradeable app-layer contract relies on admin keys or governance tokens; a compromise can drain funds. An immutable contract cannot evolve: if the proof system weakens or cryptographic assumptions change, funds and privacy are trapped behind aging infrastructure. The canonical private transfer system defined by this EIP has no admin key and no on-chain upgrade path, yet can still evolve through Ethereum's existing social-consensus hard-fork process — the same mechanism that governs every other protocol change.
Ethereum already defines valid public asset transfers at the protocol layer. This EIP extends that model with a canonical validity layer for private asset transfers.
This EIP specifies the on-chain component: the pool contract, proof system, and registries. End-to-end transaction privacy requires complementary infrastructure (mempool encryption, network-layer anonymity, wallet integration) that is out of scope.
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.
This EIP defines:
These components are presented as a single EIP because they share state and form a single deployment unit.
App-level policy (e.g., compliance wrappers, selective-disclosure protocols, fees) is out of scope for the base contract and MAY be implemented by wrapper contracts.
MIXED_LABEL) indicating provenance was lost at a merge point.[authorizingAddress, authDataCommitment, policyVersion, intentDigest]. See Section 9.1.address to (nullifierKeyHash, outputSecretHash). Leaf format: poseidon(USER_REGISTRY_LEAF_DOMAIN, uint160(user), nullifierKeyHash, outputSecretHash) (Section 3.4).outputSecretHash = poseidon(OUTPUT_SECRET_DOMAIN, outputSecret). Used only for deterministic output randomness, not for nullifier derivation or wallet-layer note delivery.depositorAddress: Public input. The deposit payer's Ethereum address; msg.sender must equal it. Nonzero selects deposit mode.recipientAddress: Private witness in the canonical 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 canonical 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 canonical intent digest. The optional private fee paid through output slot 2. 0 means no fee.publicRecipientAddress: Public input. The withdrawal destination address; zero for deposits and transfers.All Poseidon hashes that require domain separation MUST include a distinct domain tag (field element). Each domain tag is derived as:
DOMAIN = uint256(keccak256("shielded_pool.<context_name>")) mod p
where p is the BN254 scalar field order (the field over which SNARK circuits and Poseidon operate) and <context_name> is the string identifier listed below. This derivation is deterministic and removes all domain tag TBDs.
The following domain tags are defined by this EIP (all use the shielded_pool. prefix):
| Constant | Context string | Usage |
|---|---|---|
NULLIFIER_DOMAIN |
nullifier |
Real note nullifiers |
PHANTOM_DOMAIN |
phantom |
Phantom nullifiers |
LABEL_DOMAIN |
label |
Deposit labels |
INTENT_DOMAIN |
intent |
Intent nullifiers |
NK_DOMAIN |
nk |
Nullifier key hashing |
RANDOMNESS_DOMAIN |
randomness |
Deterministic output randomness |
INTENT_DIGEST_DOMAIN |
intent_digest |
Canonical intent digest |
AUTH_POLICY_DOMAIN |
auth_policy |
Auth policy registry leaves |
AUTH_POLICY_KEY_DOMAIN |
auth_policy_key |
Auth policy registry tree keys |
AUTH_VK_DOMAIN |
auth_vk |
Inner circuit VK hashing |
OUTPUT_SECRET_DOMAIN |
output_secret |
Output secret hashing |
USER_REGISTRY_LEAF_DOMAIN |
user_registry_leaf |
User registry leaves |
All values are deterministically computable from the derivation formula above and MUST be < p.
MAX_INTENT_LIFETIME = 86400 (subject to change before Review) — maximum allowed validUntilSeconds offset from block.timestamp, in seconds (24 hours).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.MIXED_LABEL — poseidon(LABEL_DOMAIN, 0xdead). Assigned to output notes when inputs have different labels (Section 12.2).DUMMY_NK_HASH — poseidon(NK_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.This EIP uses Poseidon over the BN254 scalar field p (defined in Section 3.1) with the following parameters:
t = 3 (2-arity, absorbing 2 field elements per permutation)x^5 (α = 5)R_F = 8R_P = 57This 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.
Unless otherwise stated, all Merkle trees in this EIP use hash_2(left, right) from Section 3.3.
Commitment tree. Depth-32 append-only binary Poseidon Merkle tree. Leaf indices are uint32 values in [0, 2^32 - 1], assigned sequentially from 0. Empty leaf is 0. A membership proof is an ordered list of 32 sibling nodes from leaf level upward. At height h in [0, 31], bit h of leafIndex_u32 (least-significant bit at height 0) determines whether the current hash is the left child (0) or the right child (1) when computing the parent as hash_2(left, right). For i in [0, 31], EMPTY_COMMITMENT[i + 1] = hash_2(EMPTY_COMMITMENT[i], EMPTY_COMMITMENT[i]) with EMPTY_COMMITMENT[0] = 0.
User registry tree. Depth-160 sparse binary Poseidon Merkle tree keyed by uint160(user). The key is a 160-bit big-endian bitstring; at depth d (d = 0 is MSB), bit 0 selects the left branch and bit 1 the right. Leaf value:
poseidon(USER_REGISTRY_LEAF_DOMAIN, uint160(user), nullifierKeyHash, outputSecretHash)
Empty leaf is 0. For i in [0, 159], EMPTY_USER[i + 1] = hash_2(EMPTY_USER[i], EMPTY_USER[i]) with EMPTY_USER[0] = 0.
Auth policy tree. Depth-160 sparse binary Poseidon Merkle tree. The auth-policy path is defined as the low 160 bits of poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash), interpreted big-endian. Path traversal follows the same convention as the user registry tree. Leaf value: poseidon(AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion). Empty leaf is 0. Same empty-node ladder convention.
This EIP uses a recursive proof architecture that splits the proof into two circuits with different trust properties.
Outer circuit (hard-fork-managed). There is exactly one outer circuit; it can only change via hard fork. It enforces all protocol invariants: value conservation, nullifier derivation, Merkle membership, deterministic output randomness, and registry lookups. It also recursively verifies an inner proof as part of its own verification. The outer circuit is the security boundary — a bug here can compromise the entire pool.
Inner circuit (permissionless). Anyone can write and deploy an inner circuit. It handles authentication — verifying the user's credential — and intent parsing — computing a canonical digest of what the user authorized. It outputs four public values: [authorizingAddress, authDataCommitment, policyVersion, intentDigest]. The outer circuit checks these against its own state: credentials must match the auth policy registry, and the intent digest must match the outer circuit's independent computation from execution data. Section 9.1 specifies the full per-mode constraints.
How they compose. A prover supplies the inner proof and inner verification key as private witnesses to the outer circuit. The outer circuit recursively verifies the inner proof, computes innerVkHash from the verification key, and uses it to look up the auth policy registry leaf. Because the inner verification key is a private witness, on-chain observers cannot determine which inner circuit (and therefore which auth method) was used. Section 9.1 specifies the full normative interface.
| Responsibility | Circuit | Fork required? |
|---|---|---|
| Value conservation | Outer | Yes |
| Nullifier derivation | Outer | Yes |
| Merkle membership | Outer | Yes |
| Deterministic output randomness | Outer | Yes |
| Inner proof verification | Outer | Yes |
| Auth policy registry check | Outer | Yes |
| Intent nullifier derivation | Outer | Yes |
| Canonical intent digest computation | Outer | Yes |
| Signature verification | Inner | No |
| Intent parsing | Inner | No |
| Auth data commitment binding | Inner | No |
| policyVersion authentication | Inner | No |
The outer circuit enforces protocol invariants that protect the entire pool. A weakened outer circuit could drain all funds. The inner circuit handles auth — a weakened inner circuit can only risk the registering user's funds. This separation is what makes permissionless inner circuits safe.
Auth method anonymity. All auth methods share a single outer circuit. innerVkHash is never a public input — it is checked inside the circuit against the auth policy leaf. On-chain observers cannot determine which auth method was used for a given pool transaction. Auth policy registration is public (innerVkHash appears in the AuthPolicyRegistered event); the privacy property is transaction-time only.
Output note delivery. outputNoteData0, outputNoteData1, and outputNoteData2 are hash-bound to the proof via outputNoteDataHash0, outputNoteDataHash1, and outputNoteDataHash2 (public inputs), but their contents are not semantically constrained by the circuit. The inner circuit has no role in note delivery, and the outer circuit does not enforce any encryption scheme or delivery format.
Proof generation can be delegated to a third party without granting spending authority. This section uses first-party and third-party to describe who is trusted to operate the prover; local and remote (elsewhere in this EIP) describe where computation runs. A self-hosted cloud server is first-party but remote.
Two proving configurations are supported:
First-party proving. The user controls the proving infrastructure — a local machine or self-hosted server. No third party sees transaction details beyond what is visible on-chain. Requires client software that handles nullifierKey, outputSecret, coin selection, witness construction, and any companion-standard note-delivery scheme.
Third-party proving. The user signs an intent and delegates proof generation to a specialized proving service. The prover learns all transaction details and retains discretion over coin selection and registry root selection within the valid history window. It cannot forge unauthorized operations, redirect payments, or extract funds — these properties are enforced by the proof system regardless of prover behavior. However, because the protocol does not validate note-delivery payload contents, a malicious prover can choose unusable outputNoteData at proving time and render an in-flight transfer's output notes unrecoverable.
| On-chain | Third-party prover | |
|---|---|---|
| Tx occurred | yes | yes |
| Token | deposits and withdrawals | yes |
| Amount | deposits and withdrawals | yes |
| Fee amount | no | yes |
| Fee recipient | no | yes |
| Sender | deposits | yes |
| Recipient | withdrawals | yes |
| Which notes spent | no | yes |
| Auth method used | no | yes |
Shielded transfer public inputs reveal nothing beyond the fact that a transaction occurred. Opaque note-delivery payloads (outputNoteData0, outputNoteData1, outputNoteData2) are also on-chain; their size and structure may leak metadata depending on the companion standard used. Deposits expose depositor, token, and amount; the note recipient is private. Withdrawals expose amount, recipient, and token. feeAmount and the fee note's recipient remain private in all modes; if feeRecipientAddress == 0 and feeAmount > 0, the prover chooses output slot 2's owner at proof generation time. Auth method used is hidden at the proof level for all pool transactions; auth policy registration is public. For deposits, because depositorAddress is public, observers can narrow the auth method to that address's registered auth-policy set. With first-party proving, the "Third-party prover" column does not apply.
Users MUST maintain independent backups of nullifierKey and either outputSecret or note plaintext including randomness. Loss of nullifierKey is permanent fund loss. Loss of outputSecret without note plaintext backups can make notes whose randomness has not otherwise been recovered unspendable. Companion standards MAY define additional delivery keys for note recovery, but those are not protocol requirements of this EIP.
Third-party prover persistence. A third-party prover learns nullifierKey permanently and therefore retains the ability to monitor spends of previously known notes. It also learns the current outputSecret, so it can derive output randomness until that secret is rotated. After rotateOutputSecret and stale user roots expire, the old prover can no longer derive output randomness for future transactions by that address. Companion standards MAY still define delivery-key rotation for wallet-layer note delivery, but that is separate from the protocol's outputSecret revocation path.
The shielded pool is deployed as a system contract at SHIELDED_POOL_ADDRESS (TBD), following the pattern established by EIP-4788 (beacon block root), EIP-2935 (historical block hashes), EIP-7002 (execution layer exits), and EIP-7251 (consolidations).
SHIELDED_POOL_ADDRESS can only be replaced by a subsequent hard fork that sets new code as part of its state transition rules.The pool MUST maintain:
tokenAddress is inside the commitment). The contract MUST revert if nextLeafIndex + 3 > 2^32 (since transact always inserts three commitments).COMMITMENT_ROOT_HISTORY_SIZE, consensus-critical). On each transact, the contract MUST push the pre-insertion commitment root into this buffer. The contract accepts the current root OR any historical root still in the buffer.mapping(uint256 => bool).mapping(uint256 => bool).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 nullifierKeyHash and outputSecretHash.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.mapping(address => uint256) incremented on every auth policy update (both direct and delegated). Prevents replay of delegated signatures after a direct registration.mapping(bytes32 => uint256) keyed by keccak256(abi.encodePacked(user, innerVkHash)), tracking the per-(address, innerVkHash) policyVersion counter. This mapping is the canonical source of truth for the next version to assign; the leaf value encodes the version at the time of its last write. Both are updated atomically in registerAuthPolicy.mapping(address => uint256) for EIP-712 delegated user registration replay protection.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.
The pool MUST expose the following functions:
Pool transaction:
struct PublicInputs {
uint256 merkleRoot;
uint256 nullifier0;
uint256 nullifier1;
uint256 commitment0;
uint256 commitment1;
uint256 commitment2;
uint256 publicAmountIn;
uint256 publicAmountOut;
uint256 publicRecipientAddress;
uint256 publicTokenAddress;
uint256 depositorAddress;
uint256 intentNullifier;
uint256 registryRoot;
uint256 validUntilSeconds;
uint256 executionChainId;
uint256 authPolicyRegistryRoot;
uint256 outputNoteDataHash0;
uint256 outputNoteDataHash1;
uint256 outputNoteDataHash2;
}
function transact(
bytes calldata proof,
PublicInputs calldata publicInputs,
bytes calldata outputNoteData0,
bytes calldata outputNoteData1,
bytes calldata outputNoteData2
) external payable;
User registration:
function register(
uint256 nullifierKeyHash,
uint256 outputSecretHash
) external
function registerFor(
address user,
uint256 nullifierKeyHash,
uint256 outputSecretHash,
uint256 userNonce,
bytes calldata signature
) external
register is called by msg.sender to bind their address to a nullifier key hash and output-secret hash. registerFor allows a third party to register on behalf of user using an EIP-712 signature (see Section 6.2).
function rotateOutputSecret(
uint256 newOutputSecretHash
) external
rotateOutputSecret is called by msg.sender to update only the outputSecretHash committed in the user registry. It is direct-only. The contract MUST revert if the caller is not registered. The new hash MUST be canonical (< p). The function updates the caller's user-registry leaf in place and MUST maintain the block-based user-registry root history invariant (Section 5.2.1).
Auth policy registration:
function registerAuthPolicy(
uint256 innerVkHash,
uint256 authDataCommitment
) external
function registerAuthPolicyFor(
address user,
uint256 innerVkHash,
uint256 authDataCommitment,
uint256 userNonce,
bytes calldata signature
) external
registerAuthPolicy is called by msg.sender to bind the (address, innerVkHash) pair to an auth data commitment. authDataCommitment is opaque. A single address may register multiple auth policies (one per innerVkHash); each has its own independent policyVersion.
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).uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, msg.sender, innerVkHash)) (low 160 bits; see Section 3.4).keccak256(abi.encodePacked(msg.sender, innerVkHash)) and increments policyVersion for that pair (starting from 1 on first registration).poseidon(AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion).deregisterAuthPolicy).authPolicyNonce[msg.sender].registerAuthPolicyFor allows a third party to register on behalf of user using an EIP-712 signature. It performs the same canonicality checks, key computation, policyVersion increment, leaf write, and authPolicyNonce increment as registerAuthPolicy, using user in place of msg.sender.
RegisterAuthPolicy(address user, uint256 innerVkHash, uint256 authDataCommitment, uint256 nonce).{ name: "ShieldedPool", version: "1", chainId: block.chainid, verifyingContract: SHIELDED_POOL_ADDRESS }.nonce == authPolicyNonce[user].Both functions MUST increment authPolicyNonce — this ensures a direct registration invalidates any outstanding delegated signatures. EIP-712 here is for delegated registration, not intent authorization.
Root history update: On every auth-policy registration or deregistration, the contract MUST ensure the block-based root history invariant (Section 5.2.1) is maintained.
Both methods MUST emit:
event AuthPolicyRegistered(
address indexed user,
uint256 innerVkHash,
uint256 authDataCommitment,
uint256 policyVersion
);
Auth policy deregistration:
function deregisterAuthPolicy(
uint256 innerVkHash
) external
deregisterAuthPolicy is called by msg.sender to remove an auth policy. The contract writes 0 (the empty leaf) at the auth-policy tree key uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, msg.sender, innerVkHash)) and increments authPolicyNonce[msg.sender]. Deregistration is direct-only — no delegated variant — to minimize the phishing surface for destructive operations. After stale auth-policy roots expire, no proof against that (address, innerVkHash) pair can succeed. MUST revert if the leaf is already 0. MUST emit:
event AuthPolicyDeregistered(
address indexed user,
uint256 innerVkHash
);
After deregistration, the tree state is indistinguishable from "never registered" — history is carried by events, not the current leaf. Re-registration at the same (address, innerVkHash) pair continues from the existing policyVersion counter (which is not reset by deregistration), so old intents signed at pre-deregistration versions cannot match the re-registered leaf.
Addresses without a user-registry entry cannot receive or spend notes. The default (empty) leaf in the auth policy tree is 0, denoting absence. The outer circuit requires a membership proof at the auth-policy tree key uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash)) whose leaf matches poseidon(AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion) from the inner proof outputs; an unregistered pair has leaf 0 and no valid match exists.
On each call, the pool MUST execute the following steps:
transact MUST be non-reentrant.
Verify the proof via the verification precompile using proof and publicInputs.
Verify execution chain ID. Require executionChainId == block.chainid.
Enforce intent expiry.
validUntilSeconds > 0.block.timestamp <= validUntilSeconds.Require validUntilSeconds <= block.timestamp + MAX_INTENT_LIFETIME.
Check merkle root. Require merkleRoot equals the current commitment root or is in the commitment root history.
Check registry root. Require registryRoot equals the current user registry root or is in the user registry root history. registryRoot MUST be nonzero.
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.
Enforce nullifier uniqueness. Require nullifier0 != nullifier1 (defense-in-depth). The contract MUST NOT attempt to distinguish phantom nullifiers from real ones.
Mark nullifiers spent. Require both nullifiers are unspent; then mark them spent.
Mark intent nullifier used. Require intentNullifier is unused; then mark it used.
Insert commitments. Insert commitment0, commitment1, and commitment2 into the Merkle tree. Commitments MUST be nonzero — dummy outputs use nonzero dummy commitments (inserting 0 is indistinguishable from the tree's empty leaf value).
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.
Enforce amount range. Require publicAmountIn < 2^248 and publicAmountOut < 2^248. Values in [2^248, p) pass field canonicality checks but could overflow the balance equation inside the circuit (Section 7.1).
Execute asset movement based on operation mode. Exactly one of the following three branches MUST match; the conditions are mutually exclusive:
Deposit (depositorAddress != 0):
* Enforce deposit value constraints per Section 8.1 (msg.sender == depositorAddress, publicAmountIn > 0, publicAmountOut == 0, publicRecipientAddress == 0).
* If publicTokenAddress == 0 (ETH): require msg.value == publicAmountIn.
* If publicTokenAddress != 0 (ERC-20): require msg.value == 0. Record balBefore = balanceOf(address(this)). Execute transferFrom(msg.sender, address(this), publicAmountIn) and require success. Require balanceOf(address(this)) - balBefore == publicAmountIn, else revert.
Withdrawal (depositorAddress == 0 AND publicAmountOut > 0):
* Require msg.value == 0.
* Enforce withdrawal value constraints per Section 8.3 (publicAmountIn == 0, publicRecipientAddress != 0).
* If publicTokenAddress == 0 (ETH): send publicAmountOut to publicRecipientAddress.
* If publicTokenAddress != 0 (ERC-20): execute transfer(publicRecipientAddress, publicAmountOut) and require success.
* The on-chain tx submitter MAY be a relayer whose address is irrelevant to the proof — only the intent tx signer matters.
Transfer (depositorAddress == 0 AND publicAmountOut == 0):
* Require msg.value == 0.
* Enforce transfer value constraints per Section 8.2 (publicAmountIn == 0, publicRecipientAddress == 0, publicTokenAddress == 0).
* The on-chain tx submitter MAY be a relayer whose address is irrelevant to the proof — only the intent tx signer matters.
ERC-20 transfer, transferFrom, and balanceOf calls MUST use safe call semantics that handle non-standard return values (empty return data, boolean returns, reverts).
Fee-on-transfer and rebasing tokens are incompatible. The deposit-side balance-delta check rejects fee-on-transfer tokens; rebasing tokens are not reliably detectable. Tokens that charge fees only on outbound transfer (not on transferFrom) pass the deposit check but deliver less than publicAmountOut on withdrawal. Such tokens MUST NOT be deposited.
Emit events. Emit the following event:
solidity
event ShieldedPoolTransact(
uint256 indexed nullifier0,
uint256 indexed nullifier1,
uint256 indexed intentNullifier,
uint256 commitment0,
uint256 commitment1,
uint256 commitment2,
uint256 leafIndex0,
uint256 postInsertionRoot,
bytes outputNoteData0,
bytes outputNoteData1,
bytes outputNoteData2
);
leafIndex0 is the Merkle tree leaf index of commitment0; commitment1 is always at leafIndex0 + 1, and commitment2 is always at leafIndex0 + 2. postInsertionRoot is the commitment root after all three commitments have been inserted (distinct from publicInputs.merkleRoot, which is the pre-insertion root the proof was verified against). This makes tree reconstruction from events deterministic regardless of log ordering, and saves scanners from tracking insertion count from genesis.
Nullifiers and intentNullifier are indexed for efficient scanning and lookup. Commitments, postInsertionRoot, and all three outputNoteData* fields are non-indexed. Wallets discover incoming notes by scanning ShieldedPoolTransact events and interpreting the output note data per companion standards.
Registration events:
```solidity event UserRegistered( address indexed user, uint256 nullifierKeyHash, uint256 outputSecretHash );
event OutputSecretRotated( address indexed user, uint256 outputSecretHash );
```
register and registerFor MUST emit UserRegistered. rotateOutputSecret MUST emit OutputSecretRotated. Scanners use these events to maintain local copies of the user registry tree.
The shielded pool MUST maintain a Poseidon Merkle tree mapping:
address → (nullifierKeyHash, outputSecretHash)
Root history follows the block-based model (Section 5.2.1, window: USER_REGISTRY_ROOT_HISTORY_BLOCKS).
Registration is REQUIRED before any pool operation that creates notes owned by an address. The circuit enforces that the depositor's or recipient's nullifierKeyHash matches a registry Merkle proof — an unregistered address cannot receive notes. This opt-in registration model lets the pool use ordinary Ethereum addresses as note owners, rather than requiring a separate privacy-native address format. Initial registration is a one-time operation per address (via register or the delegated registerFor flow). Withdrawal recipients (publicRecipientAddress) do not need to be registered — withdrawals send to any Ethereum address.
The contract MUST provide:
register(nullifierKeyHash, outputSecretHash) — callable by msg.sender. MUST revert if the address is already registered.registerFor(address user, nullifierKeyHash, outputSecretHash, userNonce, signature) — using an EIP-712 signature. The signature MUST commit to the address, both hashes, and a per-user registration nonce. MUST revert if the address is already registered.rotateOutputSecret(newOutputSecretHash) — callable by msg.sender. MUST revert if the address is not registered.All three methods MUST respect the block-based root history invariant (Section 5.2.1). Registration methods MUST reject nullifierKeyHash >= p or outputSecretHash >= p to prevent field aliasing between on-chain storage and in-circuit Poseidon computation. rotateOutputSecret MUST reject newOutputSecretHash >= p. All three methods MUST compute the resulting user-registry leaf and revert if it equals 0 — the zero leaf is reserved for the absent state.
The contract MUST maintain a per-user registrationNonce that increments on each successful registration and is included in the signed payload to prevent replay of old signatures.
The EIP-712 domain is { name: "ShieldedPool", version: "1", chainId: block.chainid, verifyingContract: SHIELDED_POOL_ADDRESS }. The typed struct is Register(address user, uint256 nullifierKeyHash, uint256 outputSecretHash, uint256 nonce). The contract MUST verify the signature, require nonce == registrationNonce[user], and increment registrationNonce[user] on success.
nullifierKeyHash is immutable — rotating it would make existing notes unspendable. A compromised nullifier key requires migration to a new address.
outputSecretHash is rotatable via rotateOutputSecret. Rotating it does not affect ownership of existing notes, but changes the deterministic randomness used for future outputs after stale user roots expire. After rotation, users MUST retain the prior outputSecret until the stale-root window (USER_REGISTRY_ROOT_HISTORY_BLOCKS blocks) expires and any transactions they authorized against the old root have either settled or been abandoned.
The auth policy registry binds (address, auth-method) pairs to credentials. State layout is specified in Section 5.2. Registration is via registerAuthPolicy (direct) or registerAuthPolicyFor (delegated via EIP-712). See Section 5.3.
Rotation and revocation. Auth policy rotation is bounded-delay, not instant, and operates per auth method. Other auth methods registered by the same address are unaffected. The old auth-policy root remains valid for up to AUTH_POLICY_ROOT_HISTORY_BLOCKS blocks. During this window, old intents (signed with the old policyVersion) remain provable against the stale root. If the user's own leaf changed (rotation or deregistration), the intent becomes permanently unprovable once the stale root expires.
Full revocation of a specific auth method becomes effective once the stale auth root ages out of the bounded history window. After that point:
policyVersion → inner circuit outputs the old version → mismatch with the current registry leaf → outer proof failure.policyVersion does NOT resurrect old intents. The inner circuit outputs the policyVersion from the old signed artifact, which mismatches the new leaf's incremented version.policyVersion.Adding a new auth method. To add a new auth method:
[authorizingAddress, authDataCommitment, policyVersion, intentDigest].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.Constraints: the inner circuit MUST conform to the inner-proof envelope (Section 9.1). The innerVkHash encoding follows the Barretenberg canonical serialization order (Section 9.1, step 2; to be pinned before Review). Companion ERCs MUST authenticate all canonical digest fields including policyVersion. Companion ERCs MUST domain-separate authorizations such that an authorization valid for one innerVkHash is invalid for any other innerVkHash. Auth methods requiring a different proof system need a hard fork that updates the outer circuit.
Cross-circuit note compatibility. Note commitments bind to (ownerAddress, nullifierKeyHash) — neither field encodes an auth method. A note created with any inner circuit is spendable with any other inner circuit, provided the user has registered an auth policy for the spending circuit's innerVkHash.
All inner circuits share the same note tree, nullifier set, and anonymity set — adding a new auth method requires only a new registerAuthPolicy call (creating a leaf at a new composite key), not a fund transfer. Both old and new auth methods remain usable simultaneously.
Deactivation. A user can deregister an auth method via deregisterAuthPolicy (Section 5.3), which writes the empty leaf (0) at the composite key. Deactivation is bounded-delay: the old auth-policy root remains valid for up to AUTH_POLICY_ROOT_HISTORY_BLOCKS blocks after deregistration. After expiry, no proof against that (address, innerVkHash) pair can succeed. A user may also replace credentials by re-registering with a new authDataCommitment, which increments policyVersion and invalidates old authorizations after stale roots expire. Global disabling of auth methods (e.g., pre-quantum schemes) requires a hard fork.
Inside the circuit:
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.< 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.Notes MUST commit to exactly the following fields. This EIP defines exactly one note type and one corresponding verification path. A future hard fork MAY define additional note types or migration paths, but such extensions are out of scope for this specification.
commitment = poseidon(
amount,
ownerAddress,
randomness,
nullifierKeyHash,
tokenAddress,
label
)
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.randomness — blinding factor. For newly created notes it is deterministically derived from the sender's outputSecret and intentNullifier (Section 9.5).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 12).The binary-tree Poseidon construction and exact input ordering are defined in Section 3.3.
A real input note nullifier MUST be computed as:
nullifier = poseidon(NULLIFIER_DOMAIN, nullifierKey, leafIndex_u32, randomness)
nullifierKey — 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.leafIndex_u32 — position in the Merkle tree, as u32 (not raw Field) to prevent index aliasing double-spends.randomness — the note's blinding factor.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.
The sender-side output secret MUST hash to:
outputSecretHash = poseidon(OUTPUT_SECRET_DOMAIN, outputSecret)
outputSecret is used only for deterministic output randomness. It does not affect note ownership, nullifier derivation, or wallet-layer note delivery. Unlike nullifierKey, it is rotatable through the user registry (Section 6.3).
The pool supports three operation modes, determined by public inputs:
Deposit mode is selected when depositorAddress != 0.
Requirements:
nullifierKeyHash for output note commitment binding.feeAmount != 0, output slot 2's owner MUST be registered in the user registry.msg.sender == depositorAddress.publicTokenAddress specifies the deposited asset (0 for ETH, otherwise an ERC-20 address).publicAmountIn > 0.publicAmountOut == 0.publicRecipientAddress == 0.publicAmountIn == amount + feeAmount, where amount is the canonical-intent amount and feeAmount is the optional private fee.recipientAddress (from the canonical intent digest), with amount equal to the canonical-intent amount and tokenAddress == publicTokenAddress.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 (Section 5.4, step 3).(nullifierKey, intentDigest) (Section 9.8).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.
Transfer mode is selected when:
depositorAddress == 0publicAmountIn == 0publicAmountOut == 0publicRecipientAddress == 0publicTokenAddress == 0In transfer mode the token MUST be private (enforced inside the circuit); the on-chain transaction MUST NOT reveal token or amount. The transfer anonymity set spans all tokens because publicTokenAddress is zero.
Coin selection is delegated to the prover. The intent binds payment semantics (recipient, amount, token, operation type), not which notes are spent or which labels merge. Operation-type binding is the inner circuit's responsibility via operationKind in the canonical intent digest.
Output slot 0 is the recipient payment note, output slot 1 is sender change or dummy, and output slot 2 is the fee note or dummy.
Withdrawal mode is selected when:
depositorAddress == 0publicAmountIn == 0publicAmountOut > 0publicRecipientAddress != 0publicTokenAddress 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.
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):
nullifierKey — this is why cross-circuit spending worksnullifierKeyHash and the sender's outputSecretHash against itIndependent extension axes:
The outer circuit MUST use depositorAddress (a public input) to determine the operation mode. The public-input constraints for each mode (amount directions, phantom/dummy slot requirements) are defined in Section 8. This section specifies the additional circuit-level enforcement per mode.
Inner VK Hash: innerVkHash is a Poseidon hash of the inner circuit's verification key, which uniquely identifies the inner circuit. The outer circuit computes it from the VK provided as a private witness and uses it to look up the auth policy registry. Exact VK serialization format and maximum size MUST be pinned before Review.
Deposit mode (depositorAddress != 0):
The outer circuit performs inner proof verification where authorizingAddress is the depositor and recipientAddress is the output note owner:
[authorizingAddress, authDataCommitment, policyVersion, intentDigest].innerVkHash from innerVkey (Inner VK Hash). Proves auth policy membership at key uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash)).recipientAddress is one of the witnesses used in this computation. Enforces the result matches intentDigest from the inner proof output. This binds recipientAddress to the signed intent.authorizingAddress == depositorAddress — the signer must be the depositor.authorizingAddress to the depositor's user registry entry (nullifierKeyHash, outputSecretHash).intentNullifier from (nullifierKey, intentDigest) (Section 9.8).recipientAddress — obtains the recipient's nullifierKeyHash for output note commitment binding.ownerAddress to recipientAddress.amount == feeAmount, owner determined per Section 9.5) or dummy.publicAmountIn == amount + feeAmount.The circuit must prove two or three user registry entries: depositor + recipient, and additionally output slot 2's owner if feeAmount != 0. Inner circuits for deposits MUST parse and bind authorizingAddress = depositorAddress, recipientAddress = output slot 0 owner, amount = output slot 0 amount, feeRecipientAddress, feeAmount, and tokenAddress = publicTokenAddress from the signed artifact.
Transfer/withdrawal mode (depositorAddress == 0):
The outer circuit:
innerVkey with public inputs [authorizingAddress, authDataCommitment, policyVersion, intentDigest].innerVkHash from innerVkey (Inner VK Hash).uint160(poseidon(AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash)) and proves auth policy membership at that key. The outer circuit computes poseidon(AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion) using the inner proof outputs and verifies this equals the leaf opened at the composite key. innerVkHash is bound via the composite key, not stored in the leaf.intentDigest from inner outputs matches.authorizingAddress to note ownership via user registry (nullifierKeyHash and outputSecretHash).intentNullifier from (nullifierKey, intentDigest) (Section 9.8).Inner Circuit Interface (normative):
Inner circuit public output vector — 4 field elements, fixed order:
authorizingAddress — signer's address, identified by the inner circuit's auth process. MUST come from the inner circuit.authDataCommitment — credential commitment proved against. Outer circuit checks it matches registry leaf.policyVersion — authenticated from the signed artifact. Outer circuit checks it matches registry leaf. If the registered version has changed since signing, the mismatch causes proof failure.intentDigest — canonical digest computed from parsed intent fields. Outer circuit checks it matches its own computation.innerVkHash is NOT an inner circuit output — the outer circuit computes it from the verification key used for recursive verification.
Inner-proof envelope (normative): Inner circuits MUST conform to the outer circuit's proof system and curve (UltraHONK/BN254; to be pinned before Review). Exact proof serialization format and canonical vkey encoding for innerVkHash MUST be pinned before Review. Auth methods requiring a different proof system need a hard fork.
Security property: The inner circuit MUST NOT have access to nullifierKey or outputSecret. Specifically, neither secret MUST appear as a witness or public input in the inner proof relation. The outer circuit derives intentNullifier and output randomness independently.
Normative equality constraints (MUST):
authorizingAddress from inner proof == address used for auth policy lookup, user registry lookup, nullifier derivation, and change note ownership. recipientAddress from the canonical 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 leafpolicyVersion from inner proof == policyVersion in auth policy leafintentDigest from inner proof == outer circuit's computed digestFor each input slot:
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.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.
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.)
For real input slots, the circuit MUST enforce:
poseidon(NK_DOMAIN, nullifierKey) == note.nullifierKeyHash
This binds the nullifier key to the key hash committed in the note.
For phantom input slots, the nullifier-key binding MUST be skipped.
In deposit mode (both inputs phantom), the circuit MUST still enforce that poseidon(NK_DOMAIN, nullifierKey) == registryNullifierKeyHash(authorizingAddress), where authorizingAddress is from the inner proof (constrained to equal depositorAddress per Section 9.1) and registryNullifierKeyHash is the depositor's registered nullifier key hash proven via the user registry Merkle proof. This prevents an untrusted prover from choosing an arbitrary nullifierKey for deposit outputs.
In all operation modes, the circuit MUST enforce:
poseidon(OUTPUT_SECRET_DOMAIN, outputSecret) == registryOutputSecretHash(authorizingAddress)
where registryOutputSecretHash(authorizingAddress) is extracted from the sender's user-registry leaf. This binds deterministic output randomness to a rotatable sender-side secret.
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.
For each output slot, per-slot isDummy flag (constrained to 0 or 1):
isDummy == 0 (real output): Real output notes MUST have amount > 0. The output commitment MUST be correctly formed for its owner and token. nullifierKeyHash MUST match the registry-proven key hash for that output's owner: recipient note, sender change note, or fee note. Additional per-mode constraints:ownerAddress = recipientAddress, nullifierKeyHash = recipient's registry-proven key hash, amount = authorized amount, tokenAddress = authorized token), output slot 1 is sender change or dummy (note ownerAddress = authorizingAddress, nullifierKeyHash = 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; nullifierKeyHash = that owner's registry-proven key hash; amount = feeAmount).isDummy == 1 (dummy output):amount MUST equal 0.ownerAddress MUST equal 0.tokenAddress MUST equal 0.label MUST equal 0.nullifierKeyHash MUST equal DUMMY_NK_HASH.amount == 0 constraint prevents value extraction even if a preimage for DUMMY_NK_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.feeAmount > 0 and feeRecipientAddress != 0, then ownerAddress == feeRecipientAddress.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.Output note randomness MUST be deterministically derived for both real and dummy output slots:
randomness = poseidon(RANDOMNESS_DOMAIN, outputSecret, intentNullifier, slotIndex)
Dummy outputs use the same randomness derivation as real outputs. This removes prover discretion over dummy commitments. The resulting commitment remains subject to the existing nonzero-commitment rule (Section 5.4, step 10).
For a fixed witness assignment (same input notes, same slot ordering, same accepted registryRoot, same outputSecret), output randomness is deterministic. This removes prover discretion over commitments given a fixed witness, but coin selection, slot assignment, and registry root selection (within the valid history window) are not canonicalized.
Gated by operation type:
nullifierKeyHash. The outer circuit MUST also prove the sender (authorizingAddress from inner proof) has a user registry entry, extracting both nullifierKeyHash and outputSecretHash. 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).nullifierKeyHash and outputSecretHash, so any change note binds the sender's note key and output randomness derives from the registered output secret. 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 selective-disclosure protocols, not by registry membership.authorizingAddress) has a user registry entry, extracting both the depositor's nullifierKeyHash and outputSecretHash. The circuit MUST additionally prove the recipient (recipientAddress from the canonical intent digest) has a user registry entry, extracting the recipient's nullifierKeyHash 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.outputNoteDataHash0, outputNoteDataHash1, and outputNoteDataHash2 are public inputs that bind opaque note-delivery payloads to the proof. The outer circuit treats these hashes as unconstrained pass-throughs — they are not checked against any encryption scheme or delivery format. The prover computes outputNoteDataHash0 = uint256(keccak256(outputNoteData0)) % p and includes it as a public input; the contract independently computes the same value from calldata and verifies equality. Likewise for outputs 1 and 2. This prevents third parties from substituting payloads without invalidating the proof. All 19 public inputs — including these unconstrained hash fields — are part of the verification equation; the verifier does not distinguish constrained from unconstrained public inputs.
The outer and inner circuits do not constrain the contents of the payloads. Companion standards define the interpretation, including any encryption scheme, delivery-key rotation, versioning, or note-recovery logic.
Transfer/withdrawal mode:
The outer circuit MUST enforce:
intentNullifier = poseidon(INTENT_DOMAIN, nullifierKey, intentDigest)
nullifierKey — the owner's secret; makes the nullifier unguessable.intentDigest — the canonical authorization hash (Section 9.11). Because the digest encodes the full semantic authorization (including policyVersion, authorizingAddress, rawNonce, operationKind, executionChainId), it uniquely identifies the authorized action. Replay protection is keyed to the canonical intent digest, which includes policyVersion. Since policyVersion is per (address, innerVkHash), two auth methods with different versions produce different digests and independent intent nullifiers. When two auth methods share the same policyVersion (e.g., both at version 1 after initial registration), the same semantic intent produces the same digest and intent nullifier — executing via one method prevents execution via the other.rawNonce binding: rawNonce is a private witness of the outer circuit. The outer circuit uses it in the canonical digest computation (Section 9.11). The intent nullifier consumes the digest (not rawNonce directly), so rawNonce is bound to the nullifier through the digest. Digest equality against the inner proof's intentDigest output ensures the outer circuit's rawNonce matches the value the inner circuit parsed from the signed artifact.
Range constraints: The outer circuit MUST constrain rawNonce < 2^64 and validUntilSeconds < 2^32. Without these range checks, field aliasing could allow two distinct nonce or timestamp values to produce the same intent nullifier or canonical digest (e.g., x and x + p reduce to the same field element but are different uint256 values). UNIX seconds fit 32 bits until 2106; 64 bits provides ample nonce space.
Deposit mode:
Deposits use the same derivation as transfers/withdrawals:
intentNullifier = poseidon(INTENT_DOMAIN, nullifierKey, intentDigest)
The deposit-specific fields (depositorAddress, recipientAddress, amount, feeRecipientAddress, feeAmount, publicAmountIn, publicTokenAddress) are bound through the canonical intent digest and value-conservation rules (Section 9.11).
The circuit MUST enforce output labels per Section 12.
All real input and output notes MUST use the same tokenAddress.
tokenAddress == publicTokenAddress. This binds the notes' private token to the public input that drives fund movement.publicTokenAddress == 0. Token consistency is enforced privately within the circuit.The canonical intent digest is the single semantic authorization hash binding the user's intent to the proof. Both inner and outer circuits compute it independently; the outer circuit enforces equality.
intentDigest = poseidon(
INTENT_DIGEST_DOMAIN,
policyVersion,
authorizingAddress,
operationKind,
poolAddress,
tokenAddress,
recipientAddress,
amount,
feeRecipientAddress,
feeAmount,
rawNonce,
validUntilSeconds,
executionChainId
)
poolAddress (SHIELDED_POOL_ADDRESS) makes the authorization target explicit — cheap defense-in-depth against cross-contract replay if another contract ever uses a similar digest scheme. In the outer circuit, poolAddress is a constant hardcoded at compilation time (fork-managed, like the outer verification key). In the inner circuit, it comes from the signed artifact (e.g., the EIP-712 domain's verifyingContract).intentNullifier is NOT in the digest. intentNullifier depends on nullifierKey, which the inner circuit MUST NOT access. The outer circuit derives intentNullifier from (nullifierKey, intentDigest) — see Section 9.8.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 canonical intent digest and is fixed only by the resulting proof.The outer circuit computes the digest from private witness values and public inputs. The inner circuit computes it from parsed signed intent fields. Must match. The outer circuit MUST derive operationKind from the public execution mode — it MUST NOT treat operationKind as an unconstrained witness. Derivation: depositorAddress != 0 → DEPOSIT_OP; depositorAddress == 0 AND publicAmountOut > 0 → WITHDRAWAL_OP; depositorAddress == 0 AND publicAmountOut == 0 → TRANSFER_OP.
Normative execution-field binding (MUST):
recipientAddress == publicRecipientAddress, amount == publicAmountOut, tokenAddress == publicTokenAddress, validUntilSeconds == public input, executionChainId == block.chainid (checked by contract). feeRecipientAddress and feeAmount are private. If feeAmount > 0 and feeRecipientAddress == 0, output slot 2's ownerAddress is prover-selected and privately bound by the proof.recipientAddress, amount, feeRecipientAddress, and feeAmount are private (bound through digest equality, output constraints, and value conservation), 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.authorizingAddress == depositorAddress, recipientAddress = output slot 0 owner (from signed intent), amount = output slot 0 amount, tokenAddress == publicTokenAddress, publicAmountIn == amount + feeAmount, validUntilSeconds == public input, executionChainId == block.chainid (checked by contract). If feeAmount > 0 and feeRecipientAddress == 0, output slot 2's ownerAddress is prover-selected and privately bound by the proof.The outer verifier's public-input vector is the 19 fields of PublicInputs (Section 5.3), in declaration order.
merkleRoot — commitment tree root the proof is verified against.nullifier0, nullifier1 — input note nullifiers. nullifier1 is phantom when unused.commitment0, commitment1, commitment2 — output note commitments. commitment0 is the primary user-facing output note, commitment1 is sender change or dummy, and commitment2 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.intentNullifier — replay protection.registryRoot — user registry root. MUST be nonzero.validUntilSeconds — intent expiry timestamp. MUST be > 0 for all operation modes.executionChainId — verified by the contract against block.chainid (Section 5.4, step 2). Defense-in-depth against cross-chain proof replay.authPolicyRegistryRoot — auth policy registry root. MUST be nonzero for all operation modes.outputNoteDataHash0 — uint256(keccak256(outputNoteData0)) % p. Binds the first output's opaque note-delivery payload to the proof.outputNoteDataHash1 — uint256(keccak256(outputNoteData1)) % p. Binds the second output's opaque note-delivery payload to the proof.outputNoteDataHash2 — uint256(keccak256(outputNoteData2)) % p. Binds the third output's opaque note-delivery payload to the proof.publicAmountIn and publicAmountOut apply to the token specified by publicTokenAddress. For transfers, all three are zero.
authorizingAddress, policyVersion, innerVkHash, and authDataCommitment are NOT public inputs — they are private, known only inside the circuit.
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.
The precompile verifies proofs for the outer circuit (UltraHONK/BN254; to be pinned before Review). The verification key is fork-defined. Exact proof serialization and verification key formats MUST be pinned before Review.
PROOF_VERIFY_PRECOMPILE_ADDRESS (TBD) abi.encode(bytes proof, PublicInputs publicInputs) — the struct fields are ABI-encoded as 19 consecutive uint256 values in declaration order.uint256(1) on success, empty on failure.Every note MUST carry a label field. For single-origin notes (never merged with notes from a different deposit), the label is a Poseidon hash that traces the note's lineage back to its original deposit. For mixed-origin notes, the label is MIXED_LABEL — a sentinel indicating that provenance was lost at a merge point. Labels are enforced by the circuit; they cannot be forged.
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.
MIXED_LABEL (Section 3.2). Provenance is lost at the protocol level — the label no longer traces back to a specific deposit.| Context | Inputs (in order) | Arity |
|---|---|---|
| Note commitment | amount, ownerAddress, randomness, nullifierKeyHash, tokenAddress, label |
6 |
| Nullifier | NULLIFIER_DOMAIN, nullifierKey, leafIndex_u32, randomness |
4 |
| Phantom nullifier | PHANTOM_DOMAIN, nullifierKey, intentNullifier, slotIndex |
4 |
| Nullifier key hash | NK_DOMAIN, nullifierKey |
2 |
| Output secret hash | OUTPUT_SECRET_DOMAIN, outputSecret |
2 |
| Output randomness | RANDOMNESS_DOMAIN, outputSecret, intentNullifier, slotIndex |
4 |
| Intent nullifier (all modes) | INTENT_DOMAIN, nullifierKey, intentDigest |
3 |
| Canonical intent digest | INTENT_DIGEST_DOMAIN, policyVersion, authorizingAddress, operationKind, poolAddress, tokenAddress, recipientAddress, amount, feeRecipientAddress, feeAmount, rawNonce, validUntilSeconds, executionChainId |
13 |
| Auth policy key (truncated to 160 bits) | AUTH_POLICY_KEY_DOMAIN, authorizingAddress, innerVkHash |
3 |
| Auth policy leaf | AUTH_POLICY_DOMAIN, authDataCommitment, policyVersion |
3 |
| Inner VK hash | AUTH_VK_DOMAIN, vk_elem_0, ..., vk_elem_{n-1} |
TBD |
| Deposit label | LABEL_DOMAIN, executionChainId, depositorAddress, tokenAddress, publicAmountIn, intentNullifier |
6 |
| User registry leaf | USER_REGISTRY_LEAF_DOMAIN, uint160(user), nullifierKeyHash, outputSecretHash |
4 |
| Merkle tree node | left, right |
2 |
The Merkle tree node row uses hash_2(left, right) directly — not the arity-prefixed poseidon(...) construction (Section 3.3). All other rows use the arity-prefixed form. The Inner VK hash row's exact serialization, padding, and arity MUST be pinned before Review (see Inner VK Hash in Section 9.1).
This section sketches an example inner circuit for ECDSA/secp256k1 authorization using EIP-712 typed data signing.
The user signs an EIP-712 typed struct containing all intent fields:
ShieldedPoolIntent(
uint256 policyVersion,
uint256 innerVkHash,
uint8 operationKind,
address tokenAddress,
address recipientAddress,
uint256 amount,
address feeRecipientAddress,
uint256 feeAmount,
uint64 rawNonce,
uint32 validUntilSeconds
)
EIP-712 domain: { name: "ShieldedPool", version: "1", chainId: <executionChainId>, verifyingContract: <poolAddress> }. The domain binds executionChainId and poolAddress without repeating them in the struct. innerVkHash in the signed struct satisfies the cross-innerVkHash domain separation requirement (Section 6.4): a signature valid for one inner circuit is invalid for any other. innerVkHash is not part of the canonical intent digest (Section 9.11) — it is included here only for cross-circuit domain separation.
The inner circuit:
(ecdsaPubKeyX, ecdsaPubKeyY). Derives authorizingAddress from the public key via keccak256(pubKeyX || pubKeyY)[12:].[authorizingAddress, authDataCommitment, policyVersion, intentDigest] where authDataCommitment = poseidon(ecdsaPubKeyX, ecdsaPubKeyY).outputNoteData0, outputNoteData1, and outputNoteData2 are opaque bytes emitted alongside the three output commitments in ShieldedPoolTransact. The protocol does not define an encryption scheme, delivery-key registry, or note-recovery procedure — those are defined by companion standards. The contract verifies the hash binding (Section 9.7) but MUST NOT decode or validate payload contents.
Companion standards SHOULD use constant-size real and dummy payloads to reduce structural leakage. Without a companion standard defining an interoperable note-delivery scheme, there is no canonical receive path.
When output slot 2 is used for fee compensation, the actual recipient of that note — whether designated by feeRecipientAddress or chosen by the prover when feeRecipientAddress == 0 — SHOULD receive enough offchain fee-note data to recompute commitment2 before broadcasting the transaction. Because the protocol does not validate payload semantics, a fee recipient cannot safely rely on opaque outputNoteData2 bytes alone as proof of payment.
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.
Recursion separates pool-critical logic (outer circuit, fork-managed) from spend authorization (inner circuits, user-scoped; registry lifecycle operations remain address-gated). This enables permissionless auth extensibility: new signature schemes deploy as inner circuits without a hard fork. A malicious inner circuit can only risk the registering user's funds, not the pool, because the outer circuit independently enforces value conservation, nullifiers, deterministic output randomness, and auth-policy checks — in practice, adding a new auth method is one registerAuthPolicy call with no fund transfers, no new addresses, and no anonymity set fragmentation. Existing auth methods remain active; unwanted methods can be deregistered (Section 6.4). The proving overhead vs a monolithic circuit is the cost of these properties. Decoupling the intent format from the protocol lets inner circuits evolve their signing formats independently, without coordination or a protocol change.
First-party proving is feasible today on commodity hardware — end-to-end proving takes ~20s with ~8 GB peak memory on desktop hardware (16 threads; Noir v1.0.0-beta.19 + Barretenberg 4.0.4). The protocol does not require specialized hardware for users who want to keep proof generation within infrastructure they control.
This EIP also supports non-custodial proof delegation: a user can outsource proof generation to a third party without outsourcing spending authority. The prover cannot steal funds, redirect payments, or forge unauthorized transactions. It can, however, emit unusable note-delivery payloads, making the in-flight transfer's output notes unrecoverable by the recipient. Rotating outputSecret cuts off a former prover's ability to derive future output randomness for that address. Because note delivery is not coupled to the proof system, wallets can adopt post-quantum delivery schemes without a hard fork.
Private 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.
innerVkHash, authDataCommitment, and policyVersion are private inputs, never exposed as public inputs in transact. See Section 4 for the full auth-method anonymity model.
Account-based encrypted balances reveal access patterns — which accounts transact and how frequently — even when amounts are hidden. Achieving full anonymity in an account model requires updating all N accounts per transaction, which is impractical on-chain. UTXO-based notes avoid this: spending a note produces new commitments, and shielded transfers within the pool reveal nothing about amounts, tokens, or counterparties on-chain.
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.
TBD.
Every active (address, innerVkHash) pair is an independent spend authority for the same notes (Section 6.4). The user's effective security is the minimum security of all active auth methods for that address. Registering a weak inner circuit alongside a strong one gives the security of the weak one. Users SHOULD deregister auth methods they no longer trust via deregisterAuthPolicy (Section 5.3). Deactivation is bounded-delay — the old root remains valid for AUTH_POLICY_ROOT_HISTORY_BLOCKS blocks.
Prolonged congestion can cause proofs against stale roots to fail before submission. The commitment root history is a fixed-size circular buffer (COMMITMENT_ROOT_HISTORY_SIZE entries) that advances on every transact; the user and auth policy registries use block-based windows (USER_REGISTRY_ROOT_HISTORY_BLOCKS and AUTH_POLICY_ROOT_HISTORY_BLOCKS respectively) where history entries are recorded on mutation and acceptance expires as blocks advance. Under sustained high throughput the commitment buffer is the binding constraint — users must submit proofs before the buffer wraps past their proven root.
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.
Nullifiers are append-only and not safely pruneable — removing a nullifier would allow double-spends. Each transact call adds 3 permanent storage entries (2 nullifiers + 1 intent nullifier) plus 3 commitment tree leaves. The pool uses normal storage writes and is subject to Ethereum's general state-management trajectory.
Empty or variable-size dummy payloads can leak which outputs are real. See Section 15 for payload guidance.
The block-based aging rule (at most one root history entry per block) prevents same-block churn from burning multiple history slots. An attacker making many registerAuthPolicy calls within a single block consumes at most one slot. However, an attacker can still churn across blocks by making a registration in every block over the window, filling the history with attacker-controlled roots. The buffer length bounds the cost of this attack — AUTH_POLICY_ROOT_HISTORY_BLOCKS blocks of sustained registrations. The buffer size is consensus-critical and MUST be pinned by the spec to prevent post-deployment changes that could shrink the revocation window.
A third-party prover permanently learns nullifierKey and can monitor spends of previously known notes indefinitely. It also learns the current outputSecret; rotating it via rotateOutputSecret cuts off future output randomness derivation after stale user roots expire. A compromised nullifierKey requires address migration; outputSecret compromise alone is recoverable through rotation.
Copyright and related rights waived via CC0.