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