From 59e8487b3ff9814e6273f1deb3f5d50f61044c6c Mon Sep 17 00:00:00 2001 From: Meyanis95 Date: Tue, 19 May 2026 16:37:47 -0400 Subject: [PATCH 01/10] feat(shielded-pool): draft PIR + epoch nullifiers extension spec Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shielded-pool/extensions/pir/SPEC.md | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 pocs/private-payment/shielded-pool/extensions/pir/SPEC.md diff --git a/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md b/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md new file mode 100644 index 0000000..c1ad210 --- /dev/null +++ b/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md @@ -0,0 +1,271 @@ +--- +title: "Shielded Pool: PIR + Epoch Nullifiers Extension" +status: Draft +version: 0.2.0 +authors: [] +created: 2026-05-19 +parent_spec: "../../SPEC.md" +parent_requirements: "../../../REQUIREMENTS.md" +--- + +# Shielded Pool: PIR + Epoch Nullifiers Extension + +## Overview + +The parent shielded-pool design leaves two concerns unaddressed. + +**End-user state-read privacy.** Before spending, the wallet fetches the Merkle path of the input note's commitment from the on-chain state. In practice that read goes to an RPC node. The read reveals the queried leaf index, which under KYC links to a real identity. The parent `REQUIREMENTS.md` privacy section ("Transaction patterns and timing correlation") implies this should not be observable, but the parent SPEC does not specify how the wallet performs the read. + +**Nullifier-set bloat.** The on-chain nullifier set grows linearly with transaction history and is never pruned. As the system scales, the set of entities able to host that state shrinks to a few large providers, which become a centralization and liveness risk. The parent does not enumerate this at PoC scope. + +This extension addresses both. PIR over the wallet's pre-spend tree reads closes the state-read leak. Epoch-based nullifiers bound the active on-chain set: past epochs are anchored on-chain by a single Merkle root each, and the corresponding tree content is hosted by the same off-chain state-replica server that answers PIR queries. The note format, deposit flow, and attestation flow are unchanged. + +--- + +## Conventions + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +--- + +## Diff vs Parent + +| Parent primitive | Status | +|---|---| +| `Note` data structure | Unchanged | +| `Commitment` derivation | Unchanged | +| `Nullifier` derivation | Extended: `η_e = poseidon(commitment, spending_key, epoch_id)` | +| Attestation registry and flows | Unchanged | +| `Deposit` flow | Unchanged | +| `Private Transfer` flow | Extended: commitment-tree path read via PIR; non-membership against each frozen epoch's nullifier tree, also read via PIR | +| `Withdraw` flow | Extended: same path-via-PIR and frozen-epoch non-membership inputs as `Transfer` | +| Commitment tree retrieval | Alternate fetch path (PIR-served raw nodes) | +| Historical root retrieval | Light-client-verified (replaces implicit trusted RPC) | +| `ShieldedPool` on-chain state | Extended: `currentEpoch`, `frozenNullifierRoots`, `rolloverEpoch()` | + +--- + +## Approach + +| Gap | Primitive | +|---|---| +| State-read privacy | **InsPIRe** ([eprint 2025/1352](https://eprint.iacr.org/2025/1352)). Single-server PIR with silent preprocessing (no client-side hint download). | +| Database layout for tree state | **Raw flattened Merkle node arrays**, per [`tree-pir`](https://github.com/brech1/tree-pir). Per spend, the wallet retrieves `log(N)` sibling nodes via a batched PIR query, not a precomputed path. | +| Root authenticity | **Light client** (e.g. Helios) verifies the contract storage slot holding `commitment_root` and `frozenNullifierRoots[e]` against Ethereum consensus. The PIR-served path is reconstructed against light-client-verified roots inside the wallet and inside the spend circuit. | +| Nullifier bloat | **Epoch-based nullifiers** with coarse epochs and linear non-membership composition. No recursion at PoC scope. | + +PIR-internal cryptographic parameters are inherited from InsPIRe and are not pinned in this spec. + +--- + +## Data Types + +### Nullifier (extended) + +``` +nullifier_e = poseidon(commitment, spending_key, epoch_id) +``` + +`epoch_id` is a `u64` advanced by `ShieldedPool.rolloverEpoch()`. Distinct values produce unlinkable nullifiers for the same note. + +--- + +## On-Chain State + +`ShieldedPool` MUST add: + +```solidity +uint64 public currentEpoch; +mapping(uint64 => bytes32) public frozenNullifierRoots; + +/// PoC: owner-only. Production: decentralized trigger. +function rolloverEpoch() external onlyOwner; +``` + +On `rolloverEpoch()`, the contract: + +1. Computes the Merkle root of the active `nullifiers` set and stores it in `frozenNullifierRoots[currentEpoch]`. +2. Resets the active `nullifiers` mapping. +3. Increments `currentEpoch`. +4. Emits `EpochRollover(uint64 epoch, bytes32 root)`. + +All other on-chain state from the parent SPEC is unchanged. + +--- + +## Off-Chain State-Replica Server + +A single server replicates public on-chain state and exposes one PIR endpoint. + +**Hosted data (single logical database, raw flattened Merkle nodes):** + +| Tree | Source | Used for | +|---|---|---| +| Commitment tree | `Deposit`, `Transfer` events | Sibling nodes for membership witness of input notes | +| Frozen nullifier tree (one per past epoch `e`) | `Transfer`, `Withdraw`, `EpochRollover` events | Sibling nodes for non-membership witness against `frozenNullifierRoots[e]` | + +Each tree is stored as its flattened node array, as in [`tree-pir`](https://github.com/brech1/tree-pir). The server indexes tree-id and node offset; the wallet addresses queries against `(tree_id, node_offset)`. + +The frozen nullifier trees are hosted off-chain (rather than re-derived per spend by each wallet) because the contract retains only their roots: the underlying leaves and intermediate nodes are reconstructible from `Transfer`/`Withdraw` event logs, but the per-spend cost of every wallet rebuilding every frozen tree is prohibitive. The state-replica server performs this reconstruction once and offers it as a shared service. + +The server is untrusted for correctness. Returned nodes are consumed only as part of a witness assembled and re-checked client-side. The wallet reconstructs a root from each witness and compares against the corresponding light-client-verified on-chain root. The spend circuit re-checks the same reconstruction. + +--- + +## Flows + +### Private Transfer (extended) + +``` +┌─────────┐ ┌────────────┐ ┌─────────────┐ ┌──────────────┐ +│ Wallet │ │ Light │ │ State- │ │ ShieldedPool │ +│ │ │ Client │ │ Replica/PIR │ │ │ +└────┬────┘ └─────┬──────┘ └──────┬──────┘ └──────┬───────┘ + │ 1. Verify │ │ │ + │ roots │ │ │ + │──────────────►│ │ │ + │◄──────────────│ │ │ + │ 2. Batched │ │ │ + │ PIR query │ │ │ + │ for log(N) │ │ │ + │ sibling │ │ │ + │ nodes on │ │ │ + │ commitment │ │ │ + │ tree path │ │ │ + │─────────────────────────────────►│ │ + │◄─────────────────────────────────│ │ + │ 3. For each │ │ │ + │ frozen │ │ │ + │ epoch e_j: │ │ │ + │ batched PIR │ │ │ + │ query for │ │ │ + │ low-leaf + │ │ │ + │ sibling │ │ │ + │ nodes in │ │ │ + │ frozen tree │ │ │ + │─────────────────────────────────►│ │ + │◄─────────────────────────────────│ │ + │ 4. Assemble witnesses; prove and submit via relayer │ + │─────────────────────────────────────────────────────► +``` + +**Steps:** + +1. Wallet fetches `commitment_root` and `frozenNullifierRoots[e_j]` for every `e_j` the note has lived through. These MUST be verified against Ethereum consensus via a light client. The PIR server MUST NOT be trusted for any of these values. +2. Wallet issues a batched InsPIRe query against the commitment-tree node array for the `log(N)` sibling nodes on the path from the input leaf to the root. PIR returns the requested nodes only: it does not perform the membership check. The wallet reconstructs the root from the returned nodes and asserts equality with the light-client-verified `commitment_root`. The same nodes are passed into the spend circuit as the membership witness. +3. For each frozen epoch `e_j`, the wallet computes `η_{e_j} = poseidon(commitment, spending_key, e_j)`, locates the low-leaf index in that frozen tree (the leaf whose `value < η_{e_j} < next_value`), and issues a batched PIR query for the low-leaf record and the `log(N)` sibling nodes on its path to the root. PIR returns nodes; the non-membership check itself is performed inside the spend circuit. The wallet reconstructs the root from the returned nodes and asserts equality with `frozenNullifierRoots[e_j]`. +4. Wallet assembles the witnesses, runs the extended `transfer` circuit (see below), and submits via the existing relayer path. All on-chain checks (active-epoch nullifier uniqueness, proof verification, commitment insertion) proceed as in the parent SPEC. + +### Withdraw (extended) + +Same diff as `Transfer`, applied to a single spent note. The path read against the commitment tree and the non-membership reads against each frozen nullifier tree MUST go through PIR; roots MUST be light-client-verified. + +### Epoch Rollover + +``` +┌──────────┐ ┌──────────────┐ ┌──────────────┐ +│ Operator │ │ ShieldedPool │ │ State- │ +│ │ │ │ │ Replica/PIR │ +└────┬─────┘ └──────┬───────┘ └──────┬───────┘ + │ rollover() │ │ + │──────────────►│ │ + │ │ freeze active │ + │ │ tree, commit │ + │ │ root, emit event │ + │ │──────────────────►│ + │ │ │ append frozen + │ │ │ tree nodes to + │ │ │ hosted data +``` + +--- + +## Circuit Constraints (diff) + +### Transfer Circuit + +**Additional public inputs:** + +- `current_epoch`: `u64` +- `frozen_epoch_roots[k]`: `bytes32[k]`, where `k` is the number of frozen epochs the input notes have lived through + +**Additional private inputs (per input note, per frozen epoch `e_j`):** + +- `non_membership_low_leaf_j`, `non_membership_path_j`, `non_membership_indices_j`: witness reconstructing to `frozen_epoch_roots[j]` and proving `η_{e_j}` is absent from the frozen tree + +**Additional constraints:** + +For each input note and each frozen epoch `e_j`: + +1. `η_{e_j} = poseidon(commitment_in, spending_key, e_j)` +2. The supplied non-membership witness for `η_{e_j}` reconstructs to `frozen_epoch_roots[j]` under the standard sorted-low-leaf non-membership pattern. + +For the active-epoch nullifier (replaces the parent's `poseidon2(commitment, spending_key)`): + +3. `nullifier = poseidon(commitment_in, spending_key, current_epoch)` + +Value preservation, commitment formation, and membership checks from the parent circuit are unchanged. + +### Withdraw Circuit + +Same diff as `Transfer`, applied to the single spent note. + +--- + +## Security Model + +### Threat Model (additions to parent) + +| Adversary | Capabilities | Mitigations | +|---|---|---| +| **Malicious PIR / state-replica server** | Sees PIR query traffic; knows public state; MAY serve incorrect nodes | Query privacy: InsPIRe (single-server, malicious-server model). Correctness: every returned node is consumed only as part of a root reconstruction checked against a light-client-verified on-chain root and re-checked inside the spend circuit. | +| **Untrusted RPC for root reads** | MAY misreport `commitment_root` or `frozenNullifierRoots[e]` | Roots MUST be read through a light client that verifies storage proofs against Ethereum consensus. | +| **Network observer** | Sees IP, timing, size of PIR sessions; MAY correlate sequential queries from the same wallet | Out of scope for PoC. Production deployment SHOULD use Tor or batched query windows. | + +The parent threat model (public observer, malicious relayer, compromised viewing key, malicious compliance authority) is unchanged. + +### Guarantees (additions to parent) + +| Property | Description | +|---|---| +| **Query privacy** | The state-replica server learns nothing about which tree index the wallet queried, beyond what is publicly inferable from query timing. | +| **Witness correctness** | A malicious server cannot cause the wallet to produce a valid spend against an incorrect Merkle path or non-membership witness: every reconstruction is re-checked inside the circuit against light-client-verified roots. | +| **Bounded active state** | Validators retain only the active-epoch nullifier set. Past epochs are anchored on-chain by one `bytes32` each. | + +### Limitations & Shortcuts (PoC Scope) + +| Limitation | Impact | Production Mitigation | +|---|---|---| +| No correlated-query defense | Sequential PIR sessions from the same wallet/IP still link via network metadata | Mixnet, Tor, or per-block batching | +| Linear `k` non-membership growth | Spend proof grows linearly in the number of frozen epochs the note has lived through | Coarse epochs bound `k` at PoC scope. Tachyon-style recursive aggregation collapses this to constant. | +| Centralized epoch rollover | Owner-only `rolloverEpoch()` is a single point of liveness | Decentralized trigger (per-block, per-time, or validator-voted) | +| Single state-replica server | One operator hosts the entire PIR-served database | Multiple independent replicas; wallet load-balances | +| No PIR over the encrypted note log | Note discovery still requires trial decryption or operator-side filtering | FMD / OMR (future extension) | +| No post-quantum primitives | Encryption around notes uses ECDH/AEAD as in parent | Lattice KEM and signatures (future extension) | + +--- + +## Terminology + +| Term | Definition | +|---|---| +| **PIR** | Private Information Retrieval. Protocol allowing a client to fetch row `i` from a database held by a server without revealing `i` to the server. | +| **Silent preprocessing** | A PIR preprocessing model in which all setup is server-side, with no offline hint downloaded by the client. | +| **Frozen epoch** | A past epoch whose active nullifier set has been committed to a single root on-chain. Its full tree content is hosted off-chain by the state-replica server. | +| **Non-membership witness** | A Merkle witness in sorted-low-leaf form proving that a queried value is absent from an indexed Merkle tree. | +| **State-replica server** | Off-chain service that ingests on-chain events, hosts the flattened Merkle node arrays of the commitment tree and all frozen nullifier trees, and answers PIR queries against them. | +| **Light client** | Client that verifies Ethereum chain headers and storage proofs against consensus, enabling trustless reads of `commitment_root` and `frozenNullifierRoots[e]`. | + +--- + +## References + +- InsPIRe: Mahdavi, Patel, Seo, Yeo, *Communication-Efficient PIR with Server-side Preprocessing*. IACR ePrint 2025/1352. +- Bowe, Miers, *A Note on Notes: Towards Scalable Anonymous Payments via Evolving Nullifiers and Oblivious Synchronization*. IACR ePrint 2025/2031. +- tree-pir: +- Helios light client: +- Polygon Miden, *Epoch-based nullifier database*: +- Aztec, *Global State Epochs*: +- A. Tomescu, *Notes on scaling nullifier sets*: +- Parent: [`../../SPEC.md`](../../SPEC.md) +- Parent: [`../../../REQUIREMENTS.md`](../../../REQUIREMENTS.md) From ae2aee4cfb19f65515ce13276c6133f7eeae5f62 Mon Sep 17 00:00:00 2001 From: Meyanis95 Date: Tue, 19 May 2026 16:39:56 -0400 Subject: [PATCH 02/10] chore(shielded-pool): align extension SPEC frontmatter to repo convention Co-Authored-By: Claude Opus 4.7 (1M context) --- pocs/private-payment/shielded-pool/extensions/pir/SPEC.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md b/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md index c1ad210..1f1f363 100644 --- a/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md +++ b/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md @@ -1,11 +1,11 @@ --- title: "Shielded Pool: PIR + Epoch Nullifiers Extension" status: Draft -version: 0.2.0 +version: 0.1.0 authors: [] created: 2026-05-19 -parent_spec: "../../SPEC.md" -parent_requirements: "../../../REQUIREMENTS.md" +iptf_use_case: "https://github.com/ethereum/iptf-map/blob/master/use-cases/private-stablecoins.md" +iptf_approach: "https://github.com/ethereum/iptf-map/blob/master/approaches/approach-private-payments.md" --- # Shielded Pool: PIR + Epoch Nullifiers Extension From 8a70abbfdd6ec3b49207ccf3deda0b24df8b7869 Mon Sep 17 00:00:00 2001 From: Meyanis95 Date: Wed, 20 May 2026 17:54:24 -0400 Subject: [PATCH 03/10] feat(shielded-pool): IVC chain proofs and prover-agnostic PIR spec Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shielded-pool/extensions/pir/SPEC.md | 349 ++++++++++++------ 1 file changed, 227 insertions(+), 122 deletions(-) diff --git a/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md b/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md index 1f1f363..9f3d41d 100644 --- a/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md +++ b/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md @@ -14,11 +14,13 @@ iptf_approach: "https://github.com/ethereum/iptf-map/blob/master/approaches/appr The parent shielded-pool design leaves two concerns unaddressed. -**End-user state-read privacy.** Before spending, the wallet fetches the Merkle path of the input note's commitment from the on-chain state. In practice that read goes to an RPC node. The read reveals the queried leaf index, which under KYC links to a real identity. The parent `REQUIREMENTS.md` privacy section ("Transaction patterns and timing correlation") implies this should not be observable, but the parent SPEC does not specify how the wallet performs the read. +State-read privacy. Before spending, the wallet fetches the input note's Merkle path from on-chain state. That RPC read reveals the queried leaf index, which under KYC links to a real identity. The parent `REQUIREMENTS.md` flags this; the parent SPEC does not specify how the wallet performs the read. -**Nullifier-set bloat.** The on-chain nullifier set grows linearly with transaction history and is never pruned. As the system scales, the set of entities able to host that state shrinks to a few large providers, which become a centralization and liveness risk. The parent does not enumerate this at PoC scope. +Nullifier-set bloat. The on-chain nullifier set grows linearly with history and is never pruned, shrinking the set of entities able to host it. -This extension addresses both. PIR over the wallet's pre-spend tree reads closes the state-read leak. Epoch-based nullifiers bound the active on-chain set: past epochs are anchored on-chain by a single Merkle root each, and the corresponding tree content is hosted by the same off-chain state-replica server that answers PIR queries. The note format, deposit flow, and attestation flow are unchanged. +This extension addresses both. PIR over the pre-spend tree reads closes the state-read leak. Epoch-based nullifiers bound the active on-chain set: past epochs are anchored by one Merkle root each, with tree content hosted by the same off-chain state-replica server that answers PIR queries. + +A per-note recursive chain proof (IVC) keeps per-spend work bounded: the wallet extends it one step per rollover, and the spend circuit recursively verifies one chain proof instead of inlining `k` non-membership checks. The commitment binds `epoch_created` so the verifier can enforce that the chain covers the note's full lifetime. Deposit and attestation flows are otherwise unchanged. --- @@ -28,20 +30,31 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S --- +## Proof System Requirements + +This extension is proof-system agnostic. Required capabilities: an EVM-verifiable outer artifact (spend proofs verified on-chain in O(1) gas), in-circuit recursive verification of the system's own proofs, the ability to bind a verifying key as a circuit value, zero-knowledge for spend proofs (chain-update proofs need not be zk), and support for branching the recursive verify on a base case (either via circuit-level conditionals or via a sentinel proof). + +Notation: `assert verify(vk, proof, public_inputs)` denotes the in-circuit recursive-verify primitive. `FixedVK` is the chain-update circuit's verifying key, fixed at deployment. + +Reference instantiation (non-normative): Noir (`std::verify_proof`) with the Aztec `bb_proof_verification` library on UltraHonk, using `noir-recursive-no-zk` for chain-update artifacts and `evm` for the outer spend artifact. Halo2, Plonky2/3, Nova-family folding, Risc0, and SP1 are also candidates. + +--- + ## Diff vs Parent | Parent primitive | Status | |---|---| -| `Note` data structure | Unchanged | -| `Commitment` derivation | Unchanged | +| `Note` data structure | Extended: includes `epoch_created` | +| `Commitment` derivation | Extended: `c = poseidon(token, amount, owner_pubkey, salt, epoch_created)` | | `Nullifier` derivation | Extended: `η_e = poseidon(commitment, spending_key, epoch_id)` | | Attestation registry and flows | Unchanged | -| `Deposit` flow | Unchanged | -| `Private Transfer` flow | Extended: commitment-tree path read via PIR; non-membership against each frozen epoch's nullifier tree, also read via PIR | -| `Withdraw` flow | Extended: same path-via-PIR and frozen-epoch non-membership inputs as `Transfer` | +| `Deposit` flow | Extended: circuit enforces `epoch_created == currentEpoch` on the new commitment | +| `Private Transfer` flow | Extended: commitment-tree path read via PIR; spend circuit recursively verifies one per-input-note chain proof (no `k`-fold non-membership inside the spend circuit) | +| `Withdraw` flow | Extended: same PIR path read and recursive chain-proof verification as `Transfer` | | Commitment tree retrieval | Alternate fetch path (PIR-served raw nodes) | | Historical root retrieval | Light-client-verified (replaces implicit trusted RPC) | -| `ShieldedPool` on-chain state | Extended: `currentEpoch`, `frozenNullifierRoots`, `rolloverEpoch()` | +| `ShieldedPool` on-chain state | Extended: `currentEpoch`, `frozenNullifierRoots`, `rolloverEpoch()`; spend tx supplies per-input-note `epoch_created` so the contract recomputes the expected chain accumulator from stored frozen roots | +| Per-note chain proof | New: maintained off-chain by the wallet; extended one step per epoch rollover (or `k` steps at wake-up after offline periods) | --- @@ -49,24 +62,70 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S | Gap | Primitive | |---|---| -| State-read privacy | **InsPIRe** ([eprint 2025/1352](https://eprint.iacr.org/2025/1352)). Single-server PIR with silent preprocessing (no client-side hint download). | -| Database layout for tree state | **Raw flattened Merkle node arrays**, per [`tree-pir`](https://github.com/brech1/tree-pir). Per spend, the wallet retrieves `log(N)` sibling nodes via a batched PIR query, not a precomputed path. | -| Root authenticity | **Light client** (e.g. Helios) verifies the contract storage slot holding `commitment_root` and `frozenNullifierRoots[e]` against Ethereum consensus. The PIR-served path is reconstructed against light-client-verified roots inside the wallet and inside the spend circuit. | -| Nullifier bloat | **Epoch-based nullifiers** with coarse epochs and linear non-membership composition. No recursion at PoC scope. | +| State-read privacy | InsPIRe ([eprint 2025/1352](https://eprint.iacr.org/2025/1352)): single-server PIR with silent preprocessing | +| Database layout | Raw flattened Merkle node arrays per [`tree-pir`](https://github.com/brech1/tree-pir); wallet fetches `log(N)` sibling nodes per spend via batched PIR query | +| Root authenticity | Light client (e.g. Helios) verifies `commitment_root` and `frozenNullifierRoots[e]` against consensus; paths are reconstructed against these roots in wallet and circuit | +| Nullifier bloat | Coarse epoch-based nullifiers; cross-epoch non-membership folded into a per-note recursive chain proof (IVC), verified once by the spend circuit regardless of note age | +| Spend-time per-note coverage | Commitment binds `epoch_created`, letting the verifier enforce chain coverage over the note's full lifetime | -PIR-internal cryptographic parameters are inherited from InsPIRe and are not pinned in this spec. +Artifact roles: inner (chain-update, consumed recursively) and outer (spend, verified on-chain). PIR parameters are inherited from InsPIRe. --- ## Data Types +### Note (extended) + +``` +Note { + token: address // ERC-20 token contract + amount: u128 + owner_pubkey: Point // spending public key of owner + salt: Field // random salt + epoch_created: u64 // value of currentEpoch at the moment this note was committed +} +``` + +### Commitment (extended) + +``` +commitment = poseidon(token, amount, owner_pubkey, salt, epoch_created) +``` + ### Nullifier (extended) ``` -nullifier_e = poseidon(commitment, spending_key, epoch_id) +η_e = poseidon(commitment, spending_key, epoch_id) ``` -`epoch_id` is a `u64` advanced by `ShieldedPool.rolloverEpoch()`. Distinct values produce unlinkable nullifiers for the same note. +`epoch_id` is advanced by `rolloverEpoch()`; distinct values yield distinct nullifiers for the same note across epochs. + +### Chain proof (new) + +A `ChainProof` attests that a note is non-spent through some past epoch. Produced by the chain-update circuit, maintained off-chain by the wallet. + +Public inputs: + +``` +ChainProof.public_inputs { + commitment: Field // the note this chain attests about + epoch_created: u64 // bound into commitment + epoch_validated_through: u64 // next epoch needing non-membership; equals currentEpoch when fully caught up (epoch_created ≤ epoch_validated_through ≤ currentEpoch) + accumulator: Field // running Poseidon hash over frozen roots used to build this chain +} +``` + +Epochs in `[epoch_created, epoch_validated_through - 1]` have been folded into `accumulator` and checked for non-membership. + +- Genesis: `epoch_validated_through == epoch_created`, `accumulator == 0`. +- Step: + +``` +accumulator_new = poseidon2(accumulator_prev, frozenNullifierRoots[epoch_validated_through_prev]) +epoch_validated_through_new = epoch_validated_through_prev + 1 +``` + +At spend time the contract recomputes the expected accumulator from `frozenNullifierRoots[epoch_created .. currentEpoch - 1]` and reverts on mismatch. --- @@ -77,138 +136,166 @@ nullifier_e = poseidon(commitment, spending_key, epoch_id) ```solidity uint64 public currentEpoch; mapping(uint64 => bytes32) public frozenNullifierRoots; +// Active-epoch nullifier set is epoch-namespaced so "reset" on rollover is a no-op: +mapping(uint64 => mapping(bytes32 => bool)) public activeNullifiers; /// PoC: owner-only. Production: decentralized trigger. function rolloverEpoch() external onlyOwner; + +/// View helper: recomputes the chain accumulator the spend circuit must match. +function expectedChainAccumulator(uint64 epochCreated) external view returns (bytes32); ``` -On `rolloverEpoch()`, the contract: +The active-epoch nullifier set is tracked by both a LeanIMT (for its root, frozen on rollover) and `activeNullifiers` (for O(1) uniqueness checks). The tree is updated incrementally per `Transfer`/`Withdraw` so rollover is O(1) gas. + +On `rolloverEpoch()`: read the active-tree root, write to `frozenNullifierRoots[currentEpoch]`, reset the active tree, increment `currentEpoch`, emit `EpochRollover(uint64 epoch, bytes32 root)`. -1. Computes the Merkle root of the active `nullifiers` set and stores it in `frozenNullifierRoots[currentEpoch]`. -2. Resets the active `nullifiers` mapping. -3. Increments `currentEpoch`. -4. Emits `EpochRollover(uint64 epoch, bytes32 root)`. +On every `transfer` / `withdraw`: read each input note's `epoch_created` from public inputs, revert if `accumulator != expectedChainAccumulator(epoch_created)`, insert `η_{currentEpoch}` into the active-epoch tree and `activeNullifiers[currentEpoch]`. -All other on-chain state from the parent SPEC is unchanged. +```solidity +function expectedChainAccumulator(uint64 epochCreated) public view returns (bytes32) { + bytes32 acc = bytes32(0); + for (uint64 e = epochCreated; e < currentEpoch; e++) { + acc = poseidon2(acc, frozenNullifierRoots[e]); + } + return acc; +} +``` + +Active-epoch spends are caught by the uniqueness check, not the chain. Gas cost: `O(currentEpoch - epochCreated)` per spend, bounded by coarse epochs (monthly target). --- ## Off-Chain State-Replica Server -A single server replicates public on-chain state and exposes one PIR endpoint. - -**Hosted data (single logical database, raw flattened Merkle nodes):** +One server replicates public on-chain state and exposes a PIR endpoint. Hosted data (raw flattened Merkle nodes per [`tree-pir`](https://github.com/brech1/tree-pir), addressed as `(tree_id, node_offset)`): | Tree | Source | Used for | |---|---|---| -| Commitment tree | `Deposit`, `Transfer` events | Sibling nodes for membership witness of input notes | -| Frozen nullifier tree (one per past epoch `e`) | `Transfer`, `Withdraw`, `EpochRollover` events | Sibling nodes for non-membership witness against `frozenNullifierRoots[e]` | +| Commitment tree | `Deposit`, `Transfer` events | Membership witness of input notes | +| Frozen nullifier tree (per past epoch `e`) | `Transfer`, `Withdraw`, `EpochRollover` events | Non-membership witness for the chain-update circuit | -Each tree is stored as its flattened node array, as in [`tree-pir`](https://github.com/brech1/tree-pir). The server indexes tree-id and node offset; the wallet addresses queries against `(tree_id, node_offset)`. +The contract retains only frozen-tree roots, so the server reconstructs each frozen tree once from event logs and offers it as a shared service. -The frozen nullifier trees are hosted off-chain (rather than re-derived per spend by each wallet) because the contract retains only their roots: the underlying leaves and intermediate nodes are reconstructible from `Transfer`/`Withdraw` event logs, but the per-spend cost of every wallet rebuilding every frozen tree is prohibitive. The state-replica server performs this reconstruction once and offers it as a shared service. +The server is untrusted for correctness. Returned nodes are reassembled client-side into a root and compared against the light-client-verified on-chain root; both circuits re-check the same reconstructions. -The server is untrusted for correctness. Returned nodes are consumed only as part of a witness assembled and re-checked client-side. The wallet reconstructs a root from each witness and compares against the corresponding light-client-verified on-chain root. The spend circuit re-checks the same reconstruction. +Frozen trees are append-only-then-static, so PIR preprocessing is paid once per epoch. The commitment tree grows continuously; preprocessing amortization is out of scope. --- ## Flows -### Private Transfer (extended) +The wallet maintains one `ChainProof` per owned note, updated either eagerly on each observed `EpochRollover` (one proof per rollover per held note) or lazily before spend (one proof per missed epoch, sequential). By spend time, `epoch_validated_through == currentEpoch`. -``` -┌─────────┐ ┌────────────┐ ┌─────────────┐ ┌──────────────┐ -│ Wallet │ │ Light │ │ State- │ │ ShieldedPool │ -│ │ │ Client │ │ Replica/PIR │ │ │ -└────┬────┘ └─────┬──────┘ └──────┬──────┘ └──────┬───────┘ - │ 1. Verify │ │ │ - │ roots │ │ │ - │──────────────►│ │ │ - │◄──────────────│ │ │ - │ 2. Batched │ │ │ - │ PIR query │ │ │ - │ for log(N) │ │ │ - │ sibling │ │ │ - │ nodes on │ │ │ - │ commitment │ │ │ - │ tree path │ │ │ - │─────────────────────────────────►│ │ - │◄─────────────────────────────────│ │ - │ 3. For each │ │ │ - │ frozen │ │ │ - │ epoch e_j: │ │ │ - │ batched PIR │ │ │ - │ query for │ │ │ - │ low-leaf + │ │ │ - │ sibling │ │ │ - │ nodes in │ │ │ - │ frozen tree │ │ │ - │─────────────────────────────────►│ │ - │◄─────────────────────────────────│ │ - │ 4. Assemble witnesses; prove and submit via relayer │ - │─────────────────────────────────────────────────────► -``` +### Chain Maintenance (wallet-local; new) + +1. Wallet observes `EpochRollover(e_frozen, root)` and verifies the root via the light client. +2. For each owned note with `ChainProof.epoch_validated_through == e_frozen`, the wallet computes `η_{e_frozen} = poseidon(commitment, spending_key, e_frozen)` and PIR-fetches the non-membership witness against the just-frozen tree. +3. Wallet runs the chain-update circuit, which recursively verifies the prior chain proof, reconstructs `frozenNullifierRoots[e_frozen]` from PIR-served siblings, checks `η_{e_frozen}` absent under the sorted-low-leaf pattern, folds it into the accumulator, and emits a new `ChainProof` with `epoch_validated_through = e_frozen + 1`. +4. Wallet stores the new chain proof and discards the previous one. -**Steps:** +Catch-up: an offline wallet runs steps 2-3 sequentially per missed epoch. Work is bounded-memory and resumable. -1. Wallet fetches `commitment_root` and `frozenNullifierRoots[e_j]` for every `e_j` the note has lived through. These MUST be verified against Ethereum consensus via a light client. The PIR server MUST NOT be trusted for any of these values. -2. Wallet issues a batched InsPIRe query against the commitment-tree node array for the `log(N)` sibling nodes on the path from the input leaf to the root. PIR returns the requested nodes only: it does not perform the membership check. The wallet reconstructs the root from the returned nodes and asserts equality with the light-client-verified `commitment_root`. The same nodes are passed into the spend circuit as the membership witness. -3. For each frozen epoch `e_j`, the wallet computes `η_{e_j} = poseidon(commitment, spending_key, e_j)`, locates the low-leaf index in that frozen tree (the leaf whose `value < η_{e_j} < next_value`), and issues a batched PIR query for the low-leaf record and the `log(N)` sibling nodes on its path to the root. PIR returns nodes; the non-membership check itself is performed inside the spend circuit. The wallet reconstructs the root from the returned nodes and asserts equality with `frozenNullifierRoots[e_j]`. -4. Wallet assembles the witnesses, runs the extended `transfer` circuit (see below), and submits via the existing relayer path. All on-chain checks (active-epoch nullifier uniqueness, proof verification, commitment insertion) proceed as in the parent SPEC. +### Note Genesis (sentinel chain proof) + +On note creation (via `Deposit` or as a `Transfer` output), the owner generates an initial `ChainProof` with `epoch_validated_through = epoch_created`, `accumulator = 0`. The chain-update circuit handles this via its base-case branch (`epoch_validated_through == epoch_created`), which skips prior-proof verification. + +### Private Transfer (extended) + +1. Catch up each input note's `ChainProof` to `epoch_validated_through == currentEpoch`. Notes created in the current epoch already satisfy this via their genesis proof. +2. PIR-fetch `log(N)` sibling nodes per input commitment, reconstruct the root, and assert equality with the light-client-verified `commitment_root`. +3. Run the spend circuit with per-input chain proofs, membership witnesses, spending key, and output note data (`epoch_created = currentEpoch`). +4. Submit via relayer with the proof and public inputs (per-input `(commitment, epoch_created, accumulator)` triples). +5. Contract verifies the proof, checks `accumulator == expectedChainAccumulator(epoch_created)` per input, checks `η_{currentEpoch} ∉ activeNullifiers[currentEpoch]`, inserts nullifiers and commitments, emits `Transfer`. + +The PIR server is consulted only for tree node retrievals in steps 1-2, never trusted for roots. ### Withdraw (extended) -Same diff as `Transfer`, applied to a single spent note. The path read against the commitment tree and the non-membership reads against each frozen nullifier tree MUST go through PIR; roots MUST be light-client-verified. +Same diff as `Transfer` applied to a single input note. ### Epoch Rollover -``` -┌──────────┐ ┌──────────────┐ ┌──────────────┐ -│ Operator │ │ ShieldedPool │ │ State- │ -│ │ │ │ │ Replica/PIR │ -└────┬─────┘ └──────┬───────┘ └──────┬───────┘ - │ rollover() │ │ - │──────────────►│ │ - │ │ freeze active │ - │ │ tree, commit │ - │ │ root, emit event │ - │ │──────────────────►│ - │ │ │ append frozen - │ │ │ tree nodes to - │ │ │ hosted data -``` +The operator (PoC: contract owner) calls `rolloverEpoch()`. The contract reads the active-tree root, writes it to `frozenNullifierRoots[currentEpoch]`, increments `currentEpoch`, and emits `EpochRollover`. The state-replica server ingests the event and appends the frozen tree's nodes to its hosted data. Wallets run Chain Maintenance for their notes. --- ## Circuit Constraints (diff) -### Transfer Circuit +One new circuit (chain-update) plus diffs to the parent Deposit / Transfer / Withdraw circuits. -**Additional public inputs:** +### Deposit Circuit (diff) -- `current_epoch`: `u64` -- `frozen_epoch_roots[k]`: `bytes32[k]`, where `k` is the number of frozen epochs the input notes have lived through +- New public input: `current_epoch_at_deposit: u64`. The contract enforces `current_epoch_at_deposit == currentEpoch`. +- New constraint: `commitment == poseidon(token, amount, owner_pubkey, salt, epoch_created)` with `epoch_created == current_epoch_at_deposit`. -**Additional private inputs (per input note, per frozen epoch `e_j`):** +After deposit, the wallet generates the genesis chain proof via the chain-update circuit's base-case branch. -- `non_membership_low_leaf_j`, `non_membership_path_j`, `non_membership_indices_j`: witness reconstructing to `frozen_epoch_roots[j]` and proving `η_{e_j}` is absent from the frozen tree +### Chain-Update Circuit (new) -**Additional constraints:** +Inner artifact, consumed recursively by other chain-update proofs and by the spend circuit. Used for both extension and genesis. -For each input note and each frozen epoch `e_j`: +Public inputs (the `ChainProof`): -1. `η_{e_j} = poseidon(commitment_in, spending_key, e_j)` -2. The supplied non-membership witness for `η_{e_j}` reconstructs to `frozen_epoch_roots[j]` under the standard sorted-low-leaf non-membership pattern. +- `commitment: Field` +- `epoch_created: u64` +- `epoch_validated_through: u64` +- `accumulator: Field` -For the active-epoch nullifier (replaces the parent's `poseidon2(commitment, spending_key)`): +Private inputs: -3. `nullifier = poseidon(commitment_in, spending_key, current_epoch)` +- `is_base_case: bool`. True only when `epoch_validated_through == epoch_created`. +- `prior_chain.{vk, proof, public_inputs}`: recursive witness; ignored when `is_base_case == true`. +- `frozen_root_next: Field`: equal to `frozenNullifierRoots[prior_chain.public_inputs.epoch_validated_through]`. +- `spending_key: Field` +- `non_membership.{low_leaf, low_leaf_next_value, path, indices, leaf_index}`: sorted-low-leaf witness against `frozen_root_next`. -Value preservation, commitment formation, and membership checks from the parent circuit are unchanged. +Constraints: -### Withdraw Circuit +Let `e_prev = prior_chain.public_inputs.epoch_validated_through`. -Same diff as `Transfer`, applied to the single spent note. +1. Base-case branch (`is_base_case == true`): + - `epoch_validated_through == epoch_created` + - `accumulator == 0` + - No recursive verify, no non-membership check. + +2. Inductive branch (`is_base_case == false`): + - `assert verify(prior_chain.vk, prior_chain.proof, prior_chain.public_inputs)` + - `prior_chain.vk == FixedVK` (the chain-update circuit's own VK) + - `prior_chain.public_inputs.commitment == commitment` + - `prior_chain.public_inputs.epoch_created == epoch_created` + - `epoch_validated_through == e_prev + 1` + - `accumulator == poseidon2(prior_chain.public_inputs.accumulator, frozen_root_next)` + - `η = poseidon(commitment, spending_key, e_prev)` + - Sorted-low-leaf check: `low_leaf < η < low_leaf_next_value`, and the supplied Merkle path with `low_leaf` at `leaf_index` reconstructs to `frozen_root_next`. + +Branch soundness: `is_base_case` is private but constraint set #1 forces `epoch_validated_through == epoch_created` and `accumulator == 0`. A spender cannot fake the base case for a note with `epoch_created < currentEpoch` because the spend circuit enforces `chain.epoch_validated_through == currentEpoch` and the on-chain accumulator check binds to real frozen roots. + +### Spend Circuit (Transfer / Withdraw, diff) + +Outer artifact, verified on-chain. MUST be zero-knowledge. + +New public inputs: + +- Per input note: `commitment_in: Field`, `epoch_created_in: u64`, `chain_accumulator_in: Field`. +- Global: `current_epoch: u64`. + +New private inputs (per input note): `chain_proof.{vk, proof, public_inputs}`. + +New constraints (per input note): + +1. `assert verify(chain_proof.vk, chain_proof.proof, chain_proof.public_inputs)` +2. `chain_proof.vk == FixedVK` +3. `chain_proof.public_inputs.commitment == commitment_in` +4. `chain_proof.public_inputs.epoch_created == epoch_created_in` +5. `chain_proof.public_inputs.accumulator == chain_accumulator_in` +6. `chain_proof.public_inputs.epoch_validated_through == current_epoch` (for a fresh note this holds via the base case). +7. `nullifier_active = poseidon(commitment_in, spending_key, current_epoch)` (replaces parent's `poseidon2(commitment, spending_key)`). +8. `commitment_out_i == poseidon(token_out_i, amount_out_i, owner_out_i, salt_out_i, current_epoch)` (outputs minted with `epoch_created == current_epoch`). + +Value preservation, token consistency, and commitment-tree membership are unchanged from the parent SPEC. + +Contract-side public-input checks: `chain_accumulator_in == expectedChainAccumulator(epoch_created_in)` per input, `current_epoch == self.currentEpoch`, `nullifier_active ∉ activeNullifiers[currentEpoch]`. --- @@ -218,9 +305,10 @@ Same diff as `Transfer`, applied to the single spent note. | Adversary | Capabilities | Mitigations | |---|---|---| -| **Malicious PIR / state-replica server** | Sees PIR query traffic; knows public state; MAY serve incorrect nodes | Query privacy: InsPIRe (single-server, malicious-server model). Correctness: every returned node is consumed only as part of a root reconstruction checked against a light-client-verified on-chain root and re-checked inside the spend circuit. | -| **Untrusted RPC for root reads** | MAY misreport `commitment_root` or `frozenNullifierRoots[e]` | Roots MUST be read through a light client that verifies storage proofs against Ethereum consensus. | -| **Network observer** | Sees IP, timing, size of PIR sessions; MAY correlate sequential queries from the same wallet | Out of scope for PoC. Production deployment SHOULD use Tor or batched query windows. | +| Malicious PIR / state-replica server | Sees query traffic; MAY serve incorrect nodes | InsPIRe single-server malicious-server model for privacy; every returned node is re-checked against a light-client-verified root inside a circuit | +| Untrusted RPC for root reads | MAY misreport `commitment_root` or `frozenNullifierRoots[e]` | Roots MUST be read through a light client verifying storage proofs against consensus | +| Malicious wallet attempting cross-epoch double-spend | Holds spending key; MAY spend the same note in two epochs or forge a chain against fabricated roots | `epoch_created` bound into commitment; spend circuit enforces `chain.epoch_validated_through == current_epoch`; contract enforces `accumulator == expectedChainAccumulator(epoch_created)`; chain VK constrained to `FixedVK` | +| Network observer | Sees IP, timing, size of PIR sessions | Out of scope; production SHOULD use Tor or batched windows | The parent threat model (public observer, malicious relayer, compromised viewing key, malicious compliance authority) is unchanged. @@ -228,20 +316,25 @@ The parent threat model (public observer, malicious relayer, compromised viewing | Property | Description | |---|---| -| **Query privacy** | The state-replica server learns nothing about which tree index the wallet queried, beyond what is publicly inferable from query timing. | -| **Witness correctness** | A malicious server cannot cause the wallet to produce a valid spend against an incorrect Merkle path or non-membership witness: every reconstruction is re-checked inside the circuit against light-client-verified roots. | -| **Bounded active state** | Validators retain only the active-epoch nullifier set. Past epochs are anchored on-chain by one `bytes32` each. | +| Query privacy | The state-replica server learns nothing about the queried tree index beyond what query timing publicly reveals. | +| Witness correctness | A malicious server cannot induce a valid spend against an incorrect path, non-membership witness, or chain proof: reconstructions are re-checked in-circuit against light-client roots, and accumulators are re-checked on-chain. | +| Cross-epoch double-spend safety | A note can be spent in at most one epoch. Range `[epoch_created, current_epoch - 1]` is bound by the commitment and on-chain accumulator check; the active-set uniqueness check covers the final epoch. | +| Bounded active state | Validators retain only the active-epoch nullifier set; past epochs are anchored by one `bytes32` each. | +| Constant-cost spend (when chain is current) | Spend-circuit recursion cost is independent of note age once `epoch_validated_through == currentEpoch`. | ### Limitations & Shortcuts (PoC Scope) | Limitation | Impact | Production Mitigation | |---|---|---| -| No correlated-query defense | Sequential PIR sessions from the same wallet/IP still link via network metadata | Mixnet, Tor, or per-block batching | -| Linear `k` non-membership growth | Spend proof grows linearly in the number of frozen epochs the note has lived through | Coarse epochs bound `k` at PoC scope. Tachyon-style recursive aggregation collapses this to constant. | -| Centralized epoch rollover | Owner-only `rolloverEpoch()` is a single point of liveness | Decentralized trigger (per-block, per-time, or validator-voted) | -| Single state-replica server | One operator hosts the entire PIR-served database | Multiple independent replicas; wallet load-balances | -| No PIR over the encrypted note log | Note discovery still requires trial decryption or operator-side filtering | FMD / OMR (future extension) | -| No post-quantum primitives | Encryption around notes uses ECDH/AEAD as in parent | Lattice KEM and signatures (future extension) | +| No correlated-query defense | Sequential PIR sessions from the same wallet/IP link via network metadata | Mixnet, Tor, or per-block batching | +| Chain catch-up cost on offline wallets | A wallet offline for `k` epochs pays `O(k)` sequential chain-update proofs and `O(k)` PIR queries before spending | Tachyon-style shared accumulator | +| Per-spend `O(k)` on-chain accumulator hashing | `expectedChainAccumulator` costs `O(currentEpoch - epoch_created)` SLOADs and Poseidon hashes | On-chain Merkle of frozen roots, or shared accumulator | +| Wallet maintains a chain proof per held note | Per-note state plus incremental proofs at every rollover | Shared accumulator removes per-note state | +| Centralized epoch rollover | Owner-only `rolloverEpoch()` is a liveness single point | Decentralized trigger | +| Single state-replica server | Spend liveness depends on one replica; logs allow reconstruction so funds are safe | Multiple independent replicas | +| Recursive prover maturity | Required capabilities are not uniformly stable across systems | Pin a version; track upstream releases | +| No PIR over the encrypted note log | Note discovery requires trial decryption or operator-side filtering | FMD / OMR (future extension) | +| No post-quantum primitives | Note encryption uses ECDH/AEAD as in parent | Lattice KEM and signatures | --- @@ -249,23 +342,35 @@ The parent threat model (public observer, malicious relayer, compromised viewing | Term | Definition | |---|---| -| **PIR** | Private Information Retrieval. Protocol allowing a client to fetch row `i` from a database held by a server without revealing `i` to the server. | -| **Silent preprocessing** | A PIR preprocessing model in which all setup is server-side, with no offline hint downloaded by the client. | -| **Frozen epoch** | A past epoch whose active nullifier set has been committed to a single root on-chain. Its full tree content is hosted off-chain by the state-replica server. | -| **Non-membership witness** | A Merkle witness in sorted-low-leaf form proving that a queried value is absent from an indexed Merkle tree. | -| **State-replica server** | Off-chain service that ingests on-chain events, hosts the flattened Merkle node arrays of the commitment tree and all frozen nullifier trees, and answers PIR queries against them. | -| **Light client** | Client that verifies Ethereum chain headers and storage proofs against consensus, enabling trustless reads of `commitment_root` and `frozenNullifierRoots[e]`. | +| PIR | Private Information Retrieval. Client fetches row `i` from a server-held database without revealing `i`. | +| Silent preprocessing | PIR preprocessing model with all setup server-side; no client hint download. | +| Frozen epoch | Past epoch whose nullifier set has been committed to a single root on-chain; tree content hosted off-chain. | +| Non-membership witness | Sorted-low-leaf Merkle witness proving absence from an indexed Merkle tree. | +| State-replica server | Off-chain service hosting flattened node arrays of the commitment tree and frozen nullifier trees; answers PIR queries. | +| Light client | Verifies Ethereum headers and storage proofs against consensus. | +| Chain proof | Per-note off-chain proof of non-spend from `epoch_created` through `epoch_validated_through - 1`. | +| IVC | Incrementally Verifiable Computation. Each invocation recursively verifies its predecessor; permits bounded-memory step-wise extension. | +| Base-case sentinel | Genesis chain proof with `epoch_validated_through == epoch_created` and `accumulator == 0`. | +| Chain accumulator | Running Poseidon hash binding a chain proof to the sequence of frozen roots; recomputable on-chain. | --- ## References -- InsPIRe: Mahdavi, Patel, Seo, Yeo, *Communication-Efficient PIR with Server-side Preprocessing*. IACR ePrint 2025/1352. -- Bowe, Miers, *A Note on Notes: Towards Scalable Anonymous Payments via Evolving Nullifiers and Oblivious Synchronization*. IACR ePrint 2025/2031. +Normative and background: + +- InsPIRe: Mahdavi, Patel, Seo, Yeo, "Communication-Efficient PIR with Server-side Preprocessing". IACR ePrint 2025/1352. +- Bowe, Miers, "A Note on Notes: Towards Scalable Anonymous Payments via Evolving Nullifiers and Oblivious Synchronization". IACR ePrint 2025/2031. - tree-pir: - Helios light client: -- Polygon Miden, *Epoch-based nullifier database*: -- Aztec, *Global State Epochs*: -- A. Tomescu, *Notes on scaling nullifier sets*: +- Polygon Miden, "Epoch-based nullifier database": +- Aztec, "Global State Epochs": +- A. Tomescu, "Notes on scaling nullifier sets": - Parent: [`../../SPEC.md`](../../SPEC.md) - Parent: [`../../../REQUIREMENTS.md`](../../../REQUIREMENTS.md) + +Reference instantiation (non-normative): + +- Noir standard library, "Recursion": +- Aztec `bb_proof_verification`: +- noir-examples, "Recursion": From 4b6208b2f6a154f307ca611b08f4fd7b7d472fa7 Mon Sep 17 00:00:00 2001 From: Meyanis95 Date: Wed, 20 May 2026 18:00:23 -0400 Subject: [PATCH 04/10] refactor(shielded-pool-extension): split out from shielded-pool build root Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shielded-pool-extension/README.md | 22 +++++++++++++++++++ .../pir => shielded-pool-extension}/SPEC.md | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 pocs/private-payment/shielded-pool-extension/README.md rename pocs/private-payment/{shielded-pool/extensions/pir => shielded-pool-extension}/SPEC.md (99%) diff --git a/pocs/private-payment/shielded-pool-extension/README.md b/pocs/private-payment/shielded-pool-extension/README.md new file mode 100644 index 0000000..5ae2b4d --- /dev/null +++ b/pocs/private-payment/shielded-pool-extension/README.md @@ -0,0 +1,22 @@ +# Shielded Pool Extension: PIR + Epoch Nullifiers + +A spec-only PoC that extends the [shielded-pool](../shielded-pool/) private-payments construction with two additions: + +- PIR over the wallet's pre-spend tree reads, closing the state-read privacy leak at the indexer/RPC layer. +- Epoch-based nullifiers with a per-note recursive chain proof (IVC), bounding the on-chain nullifier set without imposing linear per-spend work as notes age. + +The note format, deposit, and attestation flows of the parent are preserved. The commitment is extended to bind `epoch_created` so the on-chain verifier can enforce that a spend's chain proof covers the note's full lifetime. + +## Status + +Specification only. No implementation in this folder. The protocol is proof-system agnostic; see [SPEC.md](./SPEC.md) for the abstract requirements an implementation must meet. + +## Documents + +- [SPEC.md](./SPEC.md): protocol specification +- Parent SPEC: [`../shielded-pool/SPEC.md`](../shielded-pool/SPEC.md) +- Parent REQUIREMENTS: [`../REQUIREMENTS.md`](../REQUIREMENTS.md) + +## Security disclaimer + +Research prototype, not production-ready. Cryptographic assumptions and known shortcuts are documented in the SPEC. diff --git a/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md b/pocs/private-payment/shielded-pool-extension/SPEC.md similarity index 99% rename from pocs/private-payment/shielded-pool/extensions/pir/SPEC.md rename to pocs/private-payment/shielded-pool-extension/SPEC.md index 9f3d41d..b6d6654 100644 --- a/pocs/private-payment/shielded-pool/extensions/pir/SPEC.md +++ b/pocs/private-payment/shielded-pool-extension/SPEC.md @@ -366,8 +366,8 @@ Normative and background: - Polygon Miden, "Epoch-based nullifier database": - Aztec, "Global State Epochs": - A. Tomescu, "Notes on scaling nullifier sets": -- Parent: [`../../SPEC.md`](../../SPEC.md) -- Parent: [`../../../REQUIREMENTS.md`](../../../REQUIREMENTS.md) +- Parent SPEC: [`../shielded-pool/SPEC.md`](../shielded-pool/SPEC.md) +- Parent REQUIREMENTS: [`../REQUIREMENTS.md`](../REQUIREMENTS.md) Reference instantiation (non-normative): From 0e79a1c7e5f12c36a9917f8b73b9e135dcca01bc Mon Sep 17 00:00:00 2001 From: Meyanis95 Date: Fri, 22 May 2026 15:15:04 -0400 Subject: [PATCH 05/10] docs(shielded-pool-extension): address review feedback on PIR spec Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shielded-pool-extension/SPEC.md | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/pocs/private-payment/shielded-pool-extension/SPEC.md b/pocs/private-payment/shielded-pool-extension/SPEC.md index b6d6654..1d18a89 100644 --- a/pocs/private-payment/shielded-pool-extension/SPEC.md +++ b/pocs/private-payment/shielded-pool-extension/SPEC.md @@ -16,9 +16,9 @@ The parent shielded-pool design leaves two concerns unaddressed. State-read privacy. Before spending, the wallet fetches the input note's Merkle path from on-chain state. That RPC read reveals the queried leaf index, which under KYC links to a real identity. The parent `REQUIREMENTS.md` flags this; the parent SPEC does not specify how the wallet performs the read. -Nullifier-set bloat. The on-chain nullifier set grows linearly with history and is never pruned, shrinking the set of entities able to host it. +Nullifier-set bloat. The on-chain nullifier set grows linearly with history and is never pruned. At Visa-scale throughput (~150 M tx/day, 32 B per nullifier) this is ~5 GB/day in raw nullifier bytes alone, before tree overhead. Beyond the multi-terabyte range the set of entities able to host it shrinks to a few well-resourced providers; see Bowe and Miers (ePrint 2025/2031) for fuller analysis. -This extension addresses both. PIR over the pre-spend tree reads closes the state-read leak. Epoch-based nullifiers bound the active on-chain set: past epochs are anchored by one Merkle root each, with tree content hosted by the same off-chain state-replica server that answers PIR queries. +This extension addresses both. PIR over the pre-spend tree reads hides which leaf the wallet queried from the serving node, breaking the leaf-index-to-identity link. Epoch-based nullifiers bound the active on-chain set: past epochs are anchored by one Merkle root each, with tree content hosted by the same off-chain state-replica server that answers PIR queries. A per-note recursive chain proof (IVC) keeps per-spend work bounded: the wallet extends it one step per rollover, and the spend circuit recursively verifies one chain proof instead of inlining `k` non-membership checks. The commitment binds `epoch_created` so the verifier can enforce that the chain covers the note's full lifetime. Deposit and attestation flows are otherwise unchanged. @@ -32,9 +32,9 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ## Proof System Requirements -This extension is proof-system agnostic. Required capabilities: an EVM-verifiable outer artifact (spend proofs verified on-chain in O(1) gas), in-circuit recursive verification of the system's own proofs, the ability to bind a verifying key as a circuit value, zero-knowledge for spend proofs (chain-update proofs need not be zk), and support for branching the recursive verify on a base case (either via circuit-level conditionals or via a sentinel proof). +This extension is proof-system agnostic. Required capabilities: an EVM-verifiable outer artifact with a succinct on-chain verifier (cost depends only on public-input length, not on circuit size), in-circuit recursive verification of the system's own proofs, the ability to bind a verifying key as a circuit value, zero-knowledge for spend proofs (chain-update proofs need not be zk), and support for branching the recursive verify on a base case (via circuit-level conditionals or a sentinel proof). -Notation: `assert verify(vk, proof, public_inputs)` denotes the in-circuit recursive-verify primitive. `FixedVK` is the chain-update circuit's verifying key, fixed at deployment. +Notation: `assert recursive_verify(inner_vk, inner_statement, inner_proof)` denotes the in-circuit primitive asserting that `inner_proof` is a valid proof of `inner_statement` under `inner_vk`. All three are witnesses to the outer circuit; the outer circuit MAY re-expose any of them as its own public inputs depending on what the consuming verifier (on-chain contract or outer recursion layer) must bind to. `FixedVK` is the chain-update circuit's verifying key, fixed at deployment. Reference instantiation (non-normative): Noir (`std::verify_proof`) with the Aztec `bb_proof_verification` library on UltraHonk, using `noir-recursive-no-zk` for chain-update artifacts and `evm` for the outer spend artifact. Halo2, Plonky2/3, Nova-family folding, Risc0, and SP1 are also candidates. @@ -150,6 +150,8 @@ The active-epoch nullifier set is tracked by both a LeanIMT (for its root, froze On `rolloverEpoch()`: read the active-tree root, write to `frozenNullifierRoots[currentEpoch]`, reset the active tree, increment `currentEpoch`, emit `EpochRollover(uint64 epoch, bytes32 root)`. +Epoch cadence. The protocol does not impose an epoch duration; one epoch is whatever period elapses between two `rolloverEpoch()` calls. The PoC targets one rollover per month. Production deployments SHOULD pin a fixed cadence (e.g. one rollover every `N` blocks, or one per calendar period) so wallets and replicas can plan capacity. Coarse cadence directly bounds `k`, which sets both the per-spend on-chain accumulator hashing cost and the maximum chain-update work a wallet pays after an offline period. + On every `transfer` / `withdraw`: read each input note's `epoch_created` from public inputs, revert if `accumulator != expectedChainAccumulator(epoch_created)`, insert `η_{currentEpoch}` into the active-epoch tree and `activeNullifiers[currentEpoch]`. ```solidity @@ -179,6 +181,39 @@ The contract retains only frozen-tree roots, so the server reconstructs each fro The server is untrusted for correctness. Returned nodes are reassembled client-side into a root and compared against the light-client-verified on-chain root; both circuits re-check the same reconstructions. +Root-fetch scheduling. Wallets MUST issue `eth_getProof` for `commitment_root` and any newly-emitted `frozenNullifierRoots[e]` on a fixed schedule, independent of intent to spend: poll `commitment_root` every `T` blocks and pull `frozenNullifierRoots[e]` immediately on observing each `EpochRollover(e)` event. The RPC therefore sees a uniform stream of queries shared by every active wallet, and cannot link a fetch to an imminent spend. Privacy reduces to k-anonymity over the active user base; the only metadata that remains is "this client is a ShieldedPool user," which is already public for anyone who has ever deposited or withdrawn. + +Light-client check (pseudocode). Reconciliation walks the two-level MPT from the consensus-verified header down to the contract's storage slot: + +``` +// Inputs: contract address C, storage slot s, expected value v. +header = LightClient.latest_finalized_header() // verified vs consensus +{ accountProof, storageProof, + storageHash, value } = rpc.eth_getProof(C, [s], header.block_number) + +// Level 1: header.state_root commits to all accounts. +// Verify C's account leaf and extract its storageHash. +assert verify_mpt( + root = header.state_root, + key = keccak256(C), + leaf = rlp({nonce, balance, storageHash, codeHash}), + proof = accountProof, +) + +// Level 2: storageHash commits to all storage slots of C. +// For mapping slot: slot = keccak256(abi.encode(mappingKey, baseSlot)). +assert verify_mpt( + root = storageHash, + key = keccak256(s), + leaf = rlp(value), + proof = storageProof, +) + +assert value == v +``` + +Run once per `commitment_root` consumed at spend time, and once per `frozenNullifierRoots[e]` consumed during chain extension. + Frozen trees are append-only-then-static, so PIR preprocessing is paid once per epoch. The commitment tree grows continuously; preprocessing amortization is out of scope. --- @@ -235,6 +270,8 @@ After deposit, the wallet generates the genesis chain proof via the chain-update Inner artifact, consumed recursively by other chain-update proofs and by the spend circuit. Used for both extension and genesis. +Kept separate from the spend circuit because (a) chain-update proofs are consumed only recursively and need not be zero-knowledge or EVM-verifiable, while spend proofs are both; (b) the two have different public-input shapes; (c) keeping the recursion-frequent artifact small reduces wallet proving time across the typical update sequence. An implementation MAY merge them into one circuit with a mode flag, at the cost of paying ZK and EVM-target overhead on every chain update. + Public inputs (the `ChainProof`): - `commitment: Field` @@ -260,11 +297,11 @@ Let `e_prev = prior_chain.public_inputs.epoch_validated_through`. - No recursive verify, no non-membership check. 2. Inductive branch (`is_base_case == false`): - - `assert verify(prior_chain.vk, prior_chain.proof, prior_chain.public_inputs)` + - `assert recursive_verify(prior_chain.vk, prior_chain.public_inputs, prior_chain.proof)` - `prior_chain.vk == FixedVK` (the chain-update circuit's own VK) - `prior_chain.public_inputs.commitment == commitment` - `prior_chain.public_inputs.epoch_created == epoch_created` - - `epoch_validated_through == e_prev + 1` + - `epoch_validated_through == e_prev + 1` (advances by one rollover; see "Epoch Cadence" below for what one rollover represents) - `accumulator == poseidon2(prior_chain.public_inputs.accumulator, frozen_root_next)` - `η = poseidon(commitment, spending_key, e_prev)` - Sorted-low-leaf check: `low_leaf < η < low_leaf_next_value`, and the supplied Merkle path with `low_leaf` at `leaf_index` reconstructs to `frozen_root_next`. @@ -284,7 +321,7 @@ New private inputs (per input note): `chain_proof.{vk, proof, public_inputs}`. New constraints (per input note): -1. `assert verify(chain_proof.vk, chain_proof.proof, chain_proof.public_inputs)` +1. `assert recursive_verify(chain_proof.vk, chain_proof.public_inputs, chain_proof.proof)` 2. `chain_proof.vk == FixedVK` 3. `chain_proof.public_inputs.commitment == commitment_in` 4. `chain_proof.public_inputs.epoch_created == epoch_created_in` @@ -326,7 +363,6 @@ The parent threat model (public observer, malicious relayer, compromised viewing | Limitation | Impact | Production Mitigation | |---|---|---| -| No correlated-query defense | Sequential PIR sessions from the same wallet/IP link via network metadata | Mixnet, Tor, or per-block batching | | Chain catch-up cost on offline wallets | A wallet offline for `k` epochs pays `O(k)` sequential chain-update proofs and `O(k)` PIR queries before spending | Tachyon-style shared accumulator | | Per-spend `O(k)` on-chain accumulator hashing | `expectedChainAccumulator` costs `O(currentEpoch - epoch_created)` SLOADs and Poseidon hashes | On-chain Merkle of frozen roots, or shared accumulator | | Wallet maintains a chain proof per held note | Per-note state plus incremental proofs at every rollover | Shared accumulator removes per-note state | From 950865d6e5f8024a9ad859bbec3702de7a5d6c56 Mon Sep 17 00:00:00 2001 From: Meyanis95 Date: Tue, 26 May 2026 18:43:50 -0400 Subject: [PATCH 06/10] docs(shielded-pool-extension): indexed Merkle nullifier trees per review Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shielded-pool-extension/SPEC.md | 119 ++++++++++++------ 1 file changed, 78 insertions(+), 41 deletions(-) diff --git a/pocs/private-payment/shielded-pool-extension/SPEC.md b/pocs/private-payment/shielded-pool-extension/SPEC.md index 1d18a89..20b9779 100644 --- a/pocs/private-payment/shielded-pool-extension/SPEC.md +++ b/pocs/private-payment/shielded-pool-extension/SPEC.md @@ -22,6 +22,8 @@ This extension addresses both. PIR over the pre-spend tree reads hides which lea A per-note recursive chain proof (IVC) keeps per-spend work bounded: the wallet extends it one step per rollover, and the spend circuit recursively verifies one chain proof instead of inlining `k` non-membership checks. The commitment binds `epoch_created` so the verifier can enforce that the chain covers the note's full lifetime. Deposit and attestation flows are otherwise unchanged. +Non-membership witnesses for unspent (phantom) epochs carry no on-chain footprint, so they are served in clear; PIR is reserved for the commitment-path read and the single current-epoch nullifier lookup. This relaxes full oblivious synchronization in exchange for far fewer private queries per spend (see Off-Chain State-Replica Server and Limitations). + --- ## Conventions @@ -53,7 +55,7 @@ Reference instantiation (non-normative): Noir (`std::verify_proof`) with the Azt | `Withdraw` flow | Extended: same PIR path read and recursive chain-proof verification as `Transfer` | | Commitment tree retrieval | Alternate fetch path (PIR-served raw nodes) | | Historical root retrieval | Light-client-verified (replaces implicit trusted RPC) | -| `ShieldedPool` on-chain state | Extended: `currentEpoch`, `frozenNullifierRoots`, `rolloverEpoch()`; spend tx supplies per-input-note `epoch_created` so the contract recomputes the expected chain accumulator from stored frozen roots | +| `ShieldedPool` on-chain state | Extended: `currentEpoch`, `frozenNullifierRoots`, `activeNullifierRoot`, `rolloverEpoch()`; spend tx supplies per-input-note `epoch_created` (so the contract recomputes the expected chain accumulator) and `(pre_active_root, post_active_root)` (so the contract advances the active-tree root) | | Per-note chain proof | New: maintained off-chain by the wallet; extended one step per epoch rollover (or `k` steps at wake-up after offline periods) | --- @@ -63,10 +65,11 @@ Reference instantiation (non-normative): Noir (`std::verify_proof`) with the Azt | Gap | Primitive | |---|---| | State-read privacy | InsPIRe ([eprint 2025/1352](https://eprint.iacr.org/2025/1352)): single-server PIR with silent preprocessing | -| Database layout | Raw flattened Merkle node arrays per [`tree-pir`](https://github.com/brech1/tree-pir); wallet fetches `log(N)` sibling nodes per spend via batched PIR query | +| Database layout | Raw flattened Merkle node arrays per [`tree-pir`](https://github.com/brech1/tree-pir); wallet fetches `log(N)` sibling nodes per spend via batched PIR query. Commitment tree is a LeanIMT (append-only, membership only); active and frozen nullifier trees are indexed Merkle trees (sorted leaves with `next_value`/`next_index` pointers, supporting sorted-low-leaf non-membership and insertion) | | Root authenticity | Light client (e.g. Helios) verifies `commitment_root` and `frozenNullifierRoots[e]` against consensus; paths are reconstructed against these roots in wallet and circuit | | Nullifier bloat | Coarse epoch-based nullifiers; cross-epoch non-membership folded into a per-note recursive chain proof (IVC), verified once by the spend circuit regardless of note age | | Spend-time per-note coverage | Commitment binds `epoch_created`, letting the verifier enforce chain coverage over the note's full lifetime | +| Phantom-epoch query privacy | Nullifiers for unspent (phantom) epochs never touch the chain, so their non-membership witnesses are served in clear; PIR is reserved for the commitment read and the single current-epoch low-leaf lookup (whose nullifier becomes the on-chain spend artifact) | Artifact roles: inner (chain-update, consumed recursively) and outer (spend, verified on-chain). PIR parameters are inherited from InsPIRe. @@ -136,8 +139,10 @@ At spend time the contract recomputes the expected accumulator from `frozenNulli ```solidity uint64 public currentEpoch; mapping(uint64 => bytes32) public frozenNullifierRoots; -// Active-epoch nullifier set is epoch-namespaced so "reset" on rollover is a no-op: -mapping(uint64 => mapping(bytes32 => bool)) public activeNullifiers; +// Active-epoch nullifier set is an indexed Merkle tree; the contract holds +// only the current root, advanced by each spend and reset on rollover. +bytes32 public activeNullifierRoot; +bytes32 public constant EMPTY_IMT_ROOT = /* root of an empty indexed Merkle tree, fixed at deployment */; /// PoC: owner-only. Production: decentralized trigger. function rolloverEpoch() external onlyOwner; @@ -146,13 +151,13 @@ function rolloverEpoch() external onlyOwner; function expectedChainAccumulator(uint64 epochCreated) external view returns (bytes32); ``` -The active-epoch nullifier set is tracked by both a LeanIMT (for its root, frozen on rollover) and `activeNullifiers` (for O(1) uniqueness checks). The tree is updated incrementally per `Transfer`/`Withdraw` so rollover is O(1) gas. +The active-epoch nullifier set is an indexed Merkle tree: leaves are sorted by value and each leaf carries a `(next_value, next_index)` pointer to the next-larger leaf. Absence of η is proven with a `low_leaf` where `low_leaf.value < η < low_leaf.next_value`; insertion of η updates the predecessor's pointer and adds the new leaf. The spend circuit performs this insertion in-circuit (see Spend Circuit diff), so the contract sees only `(pre_active_root, post_active_root)` and accepts the transition by verifying the spend proof. A valid sorted-low-leaf insertion is itself a non-membership proof of η in the prior tree, so no separate `activeNullifiers` mapping or uniqueness check is needed. On-chain active-tree state is a single `bytes32`; per-leaf state is held off-chain and reconstructible from event logs. -On `rolloverEpoch()`: read the active-tree root, write to `frozenNullifierRoots[currentEpoch]`, reset the active tree, increment `currentEpoch`, emit `EpochRollover(uint64 epoch, bytes32 root)`. +On `rolloverEpoch()`: `frozenNullifierRoots[currentEpoch] = activeNullifierRoot`, then `activeNullifierRoot = EMPTY_IMT_ROOT`, then `currentEpoch += 1`, emit `EpochRollover(uint64 epoch, bytes32 root)`. Epoch cadence. The protocol does not impose an epoch duration; one epoch is whatever period elapses between two `rolloverEpoch()` calls. The PoC targets one rollover per month. Production deployments SHOULD pin a fixed cadence (e.g. one rollover every `N` blocks, or one per calendar period) so wallets and replicas can plan capacity. Coarse cadence directly bounds `k`, which sets both the per-spend on-chain accumulator hashing cost and the maximum chain-update work a wallet pays after an offline period. -On every `transfer` / `withdraw`: read each input note's `epoch_created` from public inputs, revert if `accumulator != expectedChainAccumulator(epoch_created)`, insert `η_{currentEpoch}` into the active-epoch tree and `activeNullifiers[currentEpoch]`. +On every `transfer` / `withdraw`: read each input note's `epoch_created` from public inputs, revert if `accumulator != expectedChainAccumulator(epoch_created)`, revert if `pre_active_root != activeNullifierRoot`, then set `activeNullifierRoot = post_active_root`. The spend proof internally chains `k` sorted-low-leaf insertions (one per input nullifier) from `pre_active_root` to `post_active_root`; a repeated η across two inputs of the same tx fails the second insertion because the low-leaf check would no longer be strict. ```solidity function expectedChainAccumulator(uint64 epochCreated) public view returns (bytes32) { @@ -164,7 +169,7 @@ function expectedChainAccumulator(uint64 epochCreated) public view returns (byte } ``` -Active-epoch spends are caught by the uniqueness check, not the chain. Gas cost: `O(currentEpoch - epochCreated)` per spend, bounded by coarse epochs (monthly target). +Active-epoch spends are caught by the in-circuit indexed-tree insertion: any prior occurrence of η in the active tree makes the sorted-low-leaf proof unsatisfiable, so no separate uniqueness check is needed. On-chain gas per spend: `O(currentEpoch - epochCreated)` SLOADs and Poseidon hashes in `expectedChainAccumulator`, plus a constant-size pre/post-root comparison and root write; bounded by coarse epochs (monthly target). --- @@ -172,12 +177,17 @@ Active-epoch spends are caught by the uniqueness check, not the chain. Gas cost: One server replicates public on-chain state and exposes a PIR endpoint. Hosted data (raw flattened Merkle nodes per [`tree-pir`](https://github.com/brech1/tree-pir), addressed as `(tree_id, node_offset)`): -| Tree | Source | Used for | -|---|---|---| -| Commitment tree | `Deposit`, `Transfer` events | Membership witness of input notes | -| Frozen nullifier tree (per past epoch `e`) | `Transfer`, `Withdraw`, `EpochRollover` events | Non-membership witness for the chain-update circuit | +| Tree | Construction | Source | Used for | +|---|---|---|---| +| Commitment tree | LeanIMT | `Deposit`, `Transfer` events | Membership witness of input notes (leaf-index known to wallet from its own minting event) | +| Active nullifier tree (current epoch) | Indexed Merkle tree | `Transfer`, `Withdraw` events since the last `EpochRollover` | Sorted-low-leaf insertion witness for the spend circuit's in-circuit active-tree update; PIR-served (current-epoch nullifier becomes the on-chain spend artifact) | +| Frozen nullifier tree (per past epoch `e`) | Indexed Merkle tree | `Transfer`, `Withdraw`, `EpochRollover` events | Sorted-low-leaf non-membership witness for the chain-update circuit; served in clear (phantom nullifiers have no on-chain footprint) | + +The contract retains only roots (one live `activeNullifierRoot` plus one `frozenNullifierRoots[e]` per past epoch), so the server reconstructs every tree from event logs and offers them as a shared service. -The contract retains only frozen-tree roots, so the server reconstructs each frozen tree once from event logs and offers it as a shared service. +Query model, phantom vs current epoch. A note's per-epoch nullifiers `η_e = poseidon(commitment, spending_key, e)` for `e` in `[epoch_created, currentEpoch - 1]` are phantoms: the note was not spent in those epochs, so these values never appear on-chain. With no on-chain footprint to correlate against, the wallet sends a phantom to the server in clear and receives the sorted-low-leaf non-membership witness; correctness is re-checked in-circuit against the light-client root, so a malicious witness cannot produce a valid spend. No PIR and no local index are needed for phantom epochs. + +The current-epoch nullifier `η_{currentEpoch}` is the exception: it becomes the on-chain spend artifact, so revealing it or its low-leaf neighbourhood before the transaction lands would let the server link the querying client to the spend. So the current-epoch low-leaf lookup stays private. The wallet keeps a local sorted `(value, leaf_index)` index of the current epoch's active tree (rebuilt from events since the last rollover), finds the predecessor leaf-index locally, then issues one index-addressed PIR query for that leaf and its sibling path. The commitment-membership read is PIR'd the same way, with the leaf-index known from the note's minting event. PIR is therefore consulted in exactly two places per spend; the `k - 1` phantom witnesses are served in clear. The server is untrusted for correctness. Returned nodes are reassembled client-side into a root and compared against the light-client-verified on-chain root; both circuits re-check the same reconstructions. @@ -214,7 +224,7 @@ assert value == v Run once per `commitment_root` consumed at spend time, and once per `frozenNullifierRoots[e]` consumed during chain extension. -Frozen trees are append-only-then-static, so PIR preprocessing is paid once per epoch. The commitment tree grows continuously; preprocessing amortization is out of scope. +Frozen nullifier trees are served in clear (no PIR), so no PIR preprocessing applies to them. The active nullifier tree and the commitment tree are PIR-served and mutate continuously; preprocessing amortization across those mutations is out of scope. --- @@ -225,8 +235,8 @@ The wallet maintains one `ChainProof` per owned note, updated either eagerly on ### Chain Maintenance (wallet-local; new) 1. Wallet observes `EpochRollover(e_frozen, root)` and verifies the root via the light client. -2. For each owned note with `ChainProof.epoch_validated_through == e_frozen`, the wallet computes `η_{e_frozen} = poseidon(commitment, spending_key, e_frozen)` and PIR-fetches the non-membership witness against the just-frozen tree. -3. Wallet runs the chain-update circuit, which recursively verifies the prior chain proof, reconstructs `frozenNullifierRoots[e_frozen]` from PIR-served siblings, checks `η_{e_frozen}` absent under the sorted-low-leaf pattern, folds it into the accumulator, and emits a new `ChainProof` with `epoch_validated_through = e_frozen + 1`. +2. For each owned note with `ChainProof.epoch_validated_through == e_frozen`, the wallet computes the phantom `η_{e_frozen} = poseidon(commitment, spending_key, e_frozen)` and sends it to the server in clear; the server returns the sorted-low-leaf non-membership witness against the just-frozen tree (see Off-Chain State-Replica Server, query model). +3. Wallet runs the chain-update circuit, which recursively verifies the prior chain proof, reconstructs `frozenNullifierRoots[e_frozen]` from the server-returned siblings, checks `η_{e_frozen}` absent under the sorted-low-leaf pattern, folds it into the accumulator, and emits a new `ChainProof` with `epoch_validated_through = e_frozen + 1`. 4. Wallet stores the new chain proof and discards the previous one. Catch-up: an offline wallet runs steps 2-3 sequentially per missed epoch. Work is bounded-memory and resumable. @@ -237,13 +247,14 @@ On note creation (via `Deposit` or as a `Transfer` output), the owner generates ### Private Transfer (extended) -1. Catch up each input note's `ChainProof` to `epoch_validated_through == currentEpoch`. Notes created in the current epoch already satisfy this via their genesis proof. +1. Catch up each input note's `ChainProof` to `epoch_validated_through == currentEpoch` via Chain Maintenance (phantom-epoch witnesses fetched in clear). Notes created in the current epoch already satisfy this via their genesis proof. 2. PIR-fetch `log(N)` sibling nodes per input commitment, reconstruct the root, and assert equality with the light-client-verified `commitment_root`. -3. Run the spend circuit with per-input chain proofs, membership witnesses, spending key, and output note data (`epoch_created = currentEpoch`). -4. Submit via relayer with the proof and public inputs (per-input `(commitment, epoch_created, accumulator)` triples). -5. Contract verifies the proof, checks `accumulator == expectedChainAccumulator(epoch_created)` per input, checks `η_{currentEpoch} ∉ activeNullifiers[currentEpoch]`, inserts nullifiers and commitments, emits `Transfer`. +3. For each input note's `η_{currentEpoch}`, locate the predecessor leaf-index in the wallet's local sorted index of the current epoch's active tree and PIR-fetch the predecessor leaf and its sibling path. This is the only private nullifier query, since `η_{currentEpoch}` becomes the on-chain spend artifact. The wallet then composes `k` sorted-low-leaf insertion witnesses into a chain advancing from `pre_active_root` (the on-chain current root) to `post_active_root`. +4. Run the spend circuit with per-input chain proofs, commitment-tree membership witnesses, the chained active-tree insertion witnesses, spending key, and output note data (`epoch_created = currentEpoch`). +5. Submit via relayer with the proof and public inputs: per-input `(commitment, epoch_created, accumulator)` triples plus the global `(pre_active_root, post_active_root, current_epoch)`. +6. Contract verifies the proof, checks `accumulator == expectedChainAccumulator(epoch_created)` per input and `pre_active_root == activeNullifierRoot`, sets `activeNullifierRoot = post_active_root`, inserts output commitments, emits `Transfer`. -The PIR server is consulted only for tree node retrievals in steps 1-2, never trusted for roots. +PIR is used only for the commitment read (step 2) and the current-epoch low-leaf lookup (step 3); phantom-epoch witnesses (step 1) are served in clear. The server is never trusted for roots: every returned node is re-checked in-circuit against the light-client root. ### Withdraw (extended) @@ -314,25 +325,44 @@ Outer artifact, verified on-chain. MUST be zero-knowledge. New public inputs: -- Per input note: `commitment_in: Field`, `epoch_created_in: u64`, `chain_accumulator_in: Field`. -- Global: `current_epoch: u64`. +- Per input note `i`: `commitment_in_i: Field`, `epoch_created_in_i: u64`, `chain_accumulator_in_i: Field`. +- Global: `current_epoch: u64`, `pre_active_root: Field`, `post_active_root: Field`. + +New private inputs: + +- Per input note `i`: `chain_proof_i.{vk, proof, public_inputs}`. +- Per input note `i`: `active_insertion_i.{low_leaf, low_leaf_index, low_leaf_path, new_leaf_index}`, the sorted-low-leaf insertion witness against the active nullifier tree. + +New constraints (per input note `i`): + +1. `assert recursive_verify(chain_proof_i.vk, chain_proof_i.public_inputs, chain_proof_i.proof)` +2. `chain_proof_i.vk == FixedVK` +3. `chain_proof_i.public_inputs.commitment == commitment_in_i` +4. `chain_proof_i.public_inputs.epoch_created == epoch_created_in_i` +5. `chain_proof_i.public_inputs.accumulator == chain_accumulator_in_i` +6. `chain_proof_i.public_inputs.epoch_validated_through == current_epoch` (for a fresh note this holds via the base case). +7. `nullifier_active_i = poseidon(commitment_in_i, spending_key, current_epoch)` (replaces parent's `poseidon2(commitment, spending_key)`). + +New constraint (per output note `j`): + +8. `commitment_out_j == poseidon(token_out_j, amount_out_j, owner_out_j, salt_out_j, current_epoch)` (outputs minted with `epoch_created == current_epoch`). + +Active-tree insertion chain (single pass threading the root through all `k` input nullifiers): + +Let `r_0 = pre_active_root`. For each input `i = 1..k`: -New private inputs (per input note): `chain_proof.{vk, proof, public_inputs}`. +- Sorted-low-leaf non-membership: `active_insertion_i.low_leaf.value < nullifier_active_i < active_insertion_i.low_leaf.next_value`, and `low_leaf` at `low_leaf_index` with `low_leaf_path` reconstructs to `r_{i-1}`. +- Pointer update of the predecessor: `low_leaf_updated = (value = low_leaf.value, next_value = nullifier_active_i, next_index = new_leaf_index)`. +- New leaf insertion: `new_leaf = (value = nullifier_active_i, next_value = low_leaf.next_value, next_index = low_leaf.next_index)` placed at `new_leaf_index`. +- `r_i =` root recomputed from `r_{i-1}` with both leaf updates applied along the supplied path. -New constraints (per input note): +Final constraint: `r_k == post_active_root`. -1. `assert recursive_verify(chain_proof.vk, chain_proof.public_inputs, chain_proof.proof)` -2. `chain_proof.vk == FixedVK` -3. `chain_proof.public_inputs.commitment == commitment_in` -4. `chain_proof.public_inputs.epoch_created == epoch_created_in` -5. `chain_proof.public_inputs.accumulator == chain_accumulator_in` -6. `chain_proof.public_inputs.epoch_validated_through == current_epoch` (for a fresh note this holds via the base case). -7. `nullifier_active = poseidon(commitment_in, spending_key, current_epoch)` (replaces parent's `poseidon2(commitment, spending_key)`). -8. `commitment_out_i == poseidon(token_out_i, amount_out_i, owner_out_i, salt_out_i, current_epoch)` (outputs minted with `epoch_created == current_epoch`). +Double-spend prevention follows: any prior occurrence of `nullifier_active_i` in the active tree (from a past spend, or from an earlier input in the same tx) breaks the strict `low_leaf.value < nullifier_active_i < low_leaf.next_value` inequality and makes the proof unsatisfiable. -Value preservation, token consistency, and commitment-tree membership are unchanged from the parent SPEC. +Input commitment reconstruction (changed from parent): each input note's commitment is reconstructed inside the circuit as `commitment_in_i == poseidon(token_in_i, amount_in_i, owner_in_i, salt_in_i, epoch_created_in_i)`, gaining `epoch_created_in_i` over the parent's four-field preimage, and the commitment-tree membership witness is verified against this reconstructed value. The membership-proof shape (Merkle path against `commitment_root`) is unchanged; only the leaf preimage differs. Value preservation and token consistency are unchanged from the parent SPEC. -Contract-side public-input checks: `chain_accumulator_in == expectedChainAccumulator(epoch_created_in)` per input, `current_epoch == self.currentEpoch`, `nullifier_active ∉ activeNullifiers[currentEpoch]`. +Contract-side public-input checks: `chain_accumulator_in_i == expectedChainAccumulator(epoch_created_in_i)` per input, `current_epoch == self.currentEpoch`, `pre_active_root == self.activeNullifierRoot`. On success: `self.activeNullifierRoot = post_active_root`. --- @@ -343,6 +373,7 @@ Contract-side public-input checks: `chain_accumulator_in == expectedChainAccumul | Adversary | Capabilities | Mitigations | |---|---|---| | Malicious PIR / state-replica server | Sees query traffic; MAY serve incorrect nodes | InsPIRe single-server malicious-server model for privacy; every returned node is re-checked against a light-client-verified root inside a circuit | +| Oblivious-sync server profiling a wallet | Receives phantom nullifiers `η_e` in clear; MAY profile a wallet's note ages and portfolio size, and across colluding servers link the same note across providers | Phantoms have no on-chain footprint, so the public spend stays unlinkable to these queries; the leak is per-wallet metadata to the serving party only. PoC accepts this; production SHOULD use oblivious nullifier derivation and synchronization (Bowe and Miers 2025/2031) so the service stays oblivious. See Limitations. | | Untrusted RPC for root reads | MAY misreport `commitment_root` or `frozenNullifierRoots[e]` | Roots MUST be read through a light client verifying storage proofs against consensus | | Malicious wallet attempting cross-epoch double-spend | Holds spending key; MAY spend the same note in two epochs or forge a chain against fabricated roots | `epoch_created` bound into commitment; spend circuit enforces `chain.epoch_validated_through == current_epoch`; contract enforces `accumulator == expectedChainAccumulator(epoch_created)`; chain VK constrained to `FixedVK` | | Network observer | Sees IP, timing, size of PIR sessions | Out of scope; production SHOULD use Tor or batched windows | @@ -353,21 +384,24 @@ The parent threat model (public observer, malicious relayer, compromised viewing | Property | Description | |---|---| -| Query privacy | The state-replica server learns nothing about the queried tree index beyond what query timing publicly reveals. | +| Query privacy (PIR'd reads) | For the two PIR'd reads, commitment membership and the current-epoch low-leaf lookup, the server learns nothing about the queried index beyond timing. Phantom-epoch non-membership is intentionally served in clear, so the server does learn those phantom nullifiers (see Threat Model, Limitations); this never links to the on-chain spend. | | Witness correctness | A malicious server cannot induce a valid spend against an incorrect path, non-membership witness, or chain proof: reconstructions are re-checked in-circuit against light-client roots, and accumulators are re-checked on-chain. | -| Cross-epoch double-spend safety | A note can be spent in at most one epoch. Range `[epoch_created, current_epoch - 1]` is bound by the commitment and on-chain accumulator check; the active-set uniqueness check covers the final epoch. | -| Bounded active state | Validators retain only the active-epoch nullifier set; past epochs are anchored by one `bytes32` each. | -| Constant-cost spend (when chain is current) | Spend-circuit recursion cost is independent of note age once `epoch_validated_through == currentEpoch`. | +| Cross-epoch double-spend safety | A note can be spent in at most one epoch. Range `[epoch_created, current_epoch - 1]` is bound by the commitment and on-chain accumulator check; the current epoch is covered by the in-circuit sorted-low-leaf insertion against `activeNullifierRoot`, which fails if η already appears in the active tree. | +| Bounded active state | On-chain state per epoch is one `bytes32` (`activeNullifierRoot`, overwritten on each spend, reset on rollover) plus one `bytes32` per past epoch (`frozenNullifierRoots[e]`). Per-leaf state of the active and frozen trees lives off-chain and is reconstructible from event logs, so on-chain storage does not grow per nullifier. | +| Constant-cost spend circuit (when chain is current) | Spend-circuit verification cost is independent of note age once `epoch_validated_through == currentEpoch`: one recursive chain-proof verify regardless of how many epochs the note has lived. | +| Linear contract-side accumulator check | The on-chain check is not constant: `expectedChainAccumulator(epoch_created)` costs `O(currentEpoch - epoch_created)` SLOADs and Poseidon hashes, linear in the number of frozen epochs the note spans. Bounded in practice by coarse epochs (monthly target); see Limitations for the production mitigation (on-chain Merkle of frozen roots, or shared accumulator). | ### Limitations & Shortcuts (PoC Scope) | Limitation | Impact | Production Mitigation | |---|---|---| -| Chain catch-up cost on offline wallets | A wallet offline for `k` epochs pays `O(k)` sequential chain-update proofs and `O(k)` PIR queries before spending | Tachyon-style shared accumulator | +| Chain catch-up cost on offline wallets | A wallet offline for `k` epochs pays `O(k)` sequential chain-update proofs and `O(k)` in-clear non-membership queries before spending | Tachyon-style shared accumulator | | Per-spend `O(k)` on-chain accumulator hashing | `expectedChainAccumulator` costs `O(currentEpoch - epoch_created)` SLOADs and Poseidon hashes | On-chain Merkle of frozen roots, or shared accumulator | | Wallet maintains a chain proof per held note | Per-note state plus incremental proofs at every rollover | Shared accumulator removes per-note state | | Centralized epoch rollover | Owner-only `rolloverEpoch()` is a liveness single point | Decentralized trigger | | Single state-replica server | Spend liveness depends on one replica; logs allow reconstruction so funds are safe | Multiple independent replicas | +| Active-root write contention | The active tree is advanced through a single `activeNullifierRoot`. A spend built against a `pre_active_root` that another spend has already superseded reverts, and its insertion witness (including the low-leaf predecessor) must be rebuilt against the new root. Concurrent spends therefore serialize. | Relayer/sequencer that orders spends and rebuilds witnesses, or batched multi-spend active-tree updates | +| Server-side metadata leak (in-clear phantom serving) | The state-replica server learns a wallet's phantom nullifiers, hence note ages and portfolio metadata (not its on-chain spends); colluding servers can link the same note across providers | Oblivious nullifier derivation and synchronization (Bowe and Miers 2025/2031, Project Tachyon), keeping the syncing service oblivious | | Recursive prover maturity | Required capabilities are not uniformly stable across systems | Pin a version; track upstream releases | | No PIR over the encrypted note log | Note discovery requires trial decryption or operator-side filtering | FMD / OMR (future extension) | | No post-quantum primitives | Note encryption uses ECDH/AEAD as in parent | Lattice KEM and signatures | @@ -381,8 +415,11 @@ The parent threat model (public observer, malicious relayer, compromised viewing | PIR | Private Information Retrieval. Client fetches row `i` from a server-held database without revealing `i`. | | Silent preprocessing | PIR preprocessing model with all setup server-side; no client hint download. | | Frozen epoch | Past epoch whose nullifier set has been committed to a single root on-chain; tree content hosted off-chain. | +| Indexed Merkle tree | Merkle tree whose leaves are sorted by value and carry `(next_value, next_index)` pointers to the next-larger leaf; supports both sorted-low-leaf non-membership and ordered insertion proofs. The active and frozen nullifier trees use this construction. | +| LeanIMT | Lean Incremental Merkle Tree (Semaphore); an append-only Merkle tree variant. The commitment tree uses this construction (membership only, no ordering needed). | | Non-membership witness | Sorted-low-leaf Merkle witness proving absence from an indexed Merkle tree. | -| State-replica server | Off-chain service hosting flattened node arrays of the commitment tree and frozen nullifier trees; answers PIR queries. | +| State-replica server | Off-chain service hosting flattened node arrays of the commitment tree and the active/frozen nullifier trees. Answers PIR queries for the commitment-path read and the current-epoch low-leaf lookup, and serves phantom (frozen-epoch) non-membership witnesses in clear. | +| Phantom nullifier | A note's per-epoch nullifier `η_e` for an epoch in which the note was not spent; never emitted on-chain, so it can be revealed to the state-replica server without linking to any on-chain transaction. | | Light client | Verifies Ethereum headers and storage proofs against consensus. | | Chain proof | Per-note off-chain proof of non-spend from `epoch_created` through `epoch_validated_through - 1`. | | IVC | Incrementally Verifiable Computation. Each invocation recursively verifies its predecessor; permits bounded-memory step-wise extension. | From 4be67f535878f56633027a4a4c83f5f79eaa520b Mon Sep 17 00:00:00 2001 From: Meyanis95 Date: Wed, 27 May 2026 15:36:43 -0400 Subject: [PATCH 07/10] fix(spec): fixes from comments --- .../shielded-pool-extension/SPEC.md | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/pocs/private-payment/shielded-pool-extension/SPEC.md b/pocs/private-payment/shielded-pool-extension/SPEC.md index 20b9779..9ed74a6 100644 --- a/pocs/private-payment/shielded-pool-extension/SPEC.md +++ b/pocs/private-payment/shielded-pool-extension/SPEC.md @@ -140,8 +140,9 @@ At spend time the contract recomputes the expected accumulator from `frozenNulli uint64 public currentEpoch; mapping(uint64 => bytes32) public frozenNullifierRoots; // Active-epoch nullifier set is an indexed Merkle tree; the contract holds -// only the current root, advanced by each spend and reset on rollover. +// only the current root and a leaf counter, advanced by each spend and reset on rollover. bytes32 public activeNullifierRoot; +uint64 public activeLeafCount; // next free slot = canonical append index bytes32 public constant EMPTY_IMT_ROOT = /* root of an empty indexed Merkle tree, fixed at deployment */; /// PoC: owner-only. Production: decentralized trigger. @@ -151,13 +152,15 @@ function rolloverEpoch() external onlyOwner; function expectedChainAccumulator(uint64 epochCreated) external view returns (bytes32); ``` -The active-epoch nullifier set is an indexed Merkle tree: leaves are sorted by value and each leaf carries a `(next_value, next_index)` pointer to the next-larger leaf. Absence of η is proven with a `low_leaf` where `low_leaf.value < η < low_leaf.next_value`; insertion of η updates the predecessor's pointer and adds the new leaf. The spend circuit performs this insertion in-circuit (see Spend Circuit diff), so the contract sees only `(pre_active_root, post_active_root)` and accepts the transition by verifying the spend proof. A valid sorted-low-leaf insertion is itself a non-membership proof of η in the prior tree, so no separate `activeNullifiers` mapping or uniqueness check is needed. On-chain active-tree state is a single `bytes32`; per-leaf state is held off-chain and reconstructible from event logs. +The active-epoch nullifier set is an indexed Merkle tree: leaves are sorted by value and each leaf carries a `(next_value, next_index)` pointer to the next-larger leaf. Absence of η is proven with a `low_leaf` where `low_leaf.value < η < low_leaf.next_value`. Inserting η mutates two leaves: the predecessor (its `next_value`/`next_index` repoint to η) and a freshly written leaf at the next free slot. The spend circuit performs the insertion in-circuit (see Spend Circuit diff), so the contract sees only `(pre_active_root, post_active_root)` plus the leaf count and accepts the transition by verifying the spend proof. A valid sorted-low-leaf insertion is itself a non-membership proof of η in the prior tree, so no separate `activeNullifiers` mapping or uniqueness check is needed. On-chain active-tree state is one `bytes32` and one counter; per-leaf state is held off-chain and reconstructible from event logs. -On `rolloverEpoch()`: `frozenNullifierRoots[currentEpoch] = activeNullifierRoot`, then `activeNullifierRoot = EMPTY_IMT_ROOT`, then `currentEpoch += 1`, emit `EpochRollover(uint64 epoch, bytes32 root)`. +Canonical append. New leaves are placed at sequential indices starting from `activeLeafCount`, so the post-root is a deterministic function of the inserted-nullifier sequence. A replica replaying the emitted nullifiers in block-then-input order reproduces the identical tree and root. The circuit enforces both the sequential index and that the target slot was empty, so a spend cannot overwrite an existing nullifier leaf. + +On `rolloverEpoch()`: `frozenNullifierRoots[currentEpoch] = activeNullifierRoot`, then `activeNullifierRoot = EMPTY_IMT_ROOT`, `activeLeafCount = 0`, `currentEpoch += 1`, emit `EpochRollover(uint64 epoch, bytes32 root)`. Epoch cadence. The protocol does not impose an epoch duration; one epoch is whatever period elapses between two `rolloverEpoch()` calls. The PoC targets one rollover per month. Production deployments SHOULD pin a fixed cadence (e.g. one rollover every `N` blocks, or one per calendar period) so wallets and replicas can plan capacity. Coarse cadence directly bounds `k`, which sets both the per-spend on-chain accumulator hashing cost and the maximum chain-update work a wallet pays after an offline period. -On every `transfer` / `withdraw`: read each input note's `epoch_created` from public inputs, revert if `accumulator != expectedChainAccumulator(epoch_created)`, revert if `pre_active_root != activeNullifierRoot`, then set `activeNullifierRoot = post_active_root`. The spend proof internally chains `k` sorted-low-leaf insertions (one per input nullifier) from `pre_active_root` to `post_active_root`; a repeated η across two inputs of the same tx fails the second insertion because the low-leaf check would no longer be strict. +On every `transfer` / `withdraw`: read each input note's `epoch_created` from public inputs, revert if `accumulator != expectedChainAccumulator(epoch_created)`, revert if `pre_active_root != activeNullifierRoot` or `pre_leaf_count != activeLeafCount`, then set `activeNullifierRoot = post_active_root` and `activeLeafCount += k`. The spend proof internally chains `k` sorted-low-leaf insertions (one per input nullifier) from `pre_active_root` to `post_active_root`, appending at indices `pre_leaf_count .. pre_leaf_count + k - 1`; a repeated η across two inputs of the same tx fails the second insertion because the low-leaf check would no longer be strict. ```solidity function expectedChainAccumulator(uint64 epochCreated) public view returns (bytes32) { @@ -251,8 +254,8 @@ On note creation (via `Deposit` or as a `Transfer` output), the owner generates 2. PIR-fetch `log(N)` sibling nodes per input commitment, reconstruct the root, and assert equality with the light-client-verified `commitment_root`. 3. For each input note's `η_{currentEpoch}`, locate the predecessor leaf-index in the wallet's local sorted index of the current epoch's active tree and PIR-fetch the predecessor leaf and its sibling path. This is the only private nullifier query, since `η_{currentEpoch}` becomes the on-chain spend artifact. The wallet then composes `k` sorted-low-leaf insertion witnesses into a chain advancing from `pre_active_root` (the on-chain current root) to `post_active_root`. 4. Run the spend circuit with per-input chain proofs, commitment-tree membership witnesses, the chained active-tree insertion witnesses, spending key, and output note data (`epoch_created = currentEpoch`). -5. Submit via relayer with the proof and public inputs: per-input `(commitment, epoch_created, accumulator)` triples plus the global `(pre_active_root, post_active_root, current_epoch)`. -6. Contract verifies the proof, checks `accumulator == expectedChainAccumulator(epoch_created)` per input and `pre_active_root == activeNullifierRoot`, sets `activeNullifierRoot = post_active_root`, inserts output commitments, emits `Transfer`. +5. Submit via relayer with the proof and public inputs: per-input `(nullifier_active, epoch_created, accumulator)` triples plus the global `(pre_active_root, post_active_root, pre_leaf_count, current_epoch)`. Input commitments stay private. +6. Contract verifies the proof, checks `accumulator == expectedChainAccumulator(epoch_created)` per input and `pre_active_root == activeNullifierRoot`, sets `activeNullifierRoot = post_active_root`, inserts output commitments, emits `Transfer` carrying the per-input `nullifier_active` values so replicas can rebuild the active indexed tree. PIR is used only for the commitment read (step 2) and the current-epoch low-leaf lookup (step 3); phantom-epoch witnesses (step 1) are served in clear. The server is never trusted for roots: every returned node is re-checked in-circuit against the light-client root. @@ -296,6 +299,7 @@ Private inputs: - `prior_chain.{vk, proof, public_inputs}`: recursive witness; ignored when `is_base_case == true`. - `frozen_root_next: Field`: equal to `frozenNullifierRoots[prior_chain.public_inputs.epoch_validated_through]`. - `spending_key: Field` +- `token, amount, salt: Field`: the input note's remaining commitment preimage (`commitment` and `epoch_created` are already public inputs). - `non_membership.{low_leaf, low_leaf_next_value, path, indices, leaf_index}`: sorted-low-leaf witness against `frozen_root_next`. Constraints: @@ -314,6 +318,7 @@ Let `e_prev = prior_chain.public_inputs.epoch_validated_through`. - `prior_chain.public_inputs.epoch_created == epoch_created` - `epoch_validated_through == e_prev + 1` (advances by one rollover; see "Epoch Cadence" below for what one rollover represents) - `accumulator == poseidon2(prior_chain.public_inputs.accumulator, frozen_root_next)` + - Key binding: `owner_pubkey == poseidon(spending_key)` and `commitment == poseidon(token, amount, owner_pubkey, salt, epoch_created)`. The public `commitment` thus pins `spending_key` to the note's real owner key, so the nullifier below is the one that would actually have been published if the note were spent in `e_prev`. Without this, a prover could supply a fabricated `spending_key`, derive a phantom η that is trivially absent, and pass non-membership while the real nullifier sits in the frozen tree. - `η = poseidon(commitment, spending_key, e_prev)` - Sorted-low-leaf check: `low_leaf < η < low_leaf_next_value`, and the supplied Merkle path with `low_leaf` at `leaf_index` reconstructs to `frozen_root_next`. @@ -325,13 +330,16 @@ Outer artifact, verified on-chain. MUST be zero-knowledge. New public inputs: -- Per input note `i`: `commitment_in_i: Field`, `epoch_created_in_i: u64`, `chain_accumulator_in_i: Field`. -- Global: `current_epoch: u64`, `pre_active_root: Field`, `post_active_root: Field`. +- Per input note `i`: `nullifier_active_i: Field`, `epoch_created_in_i: u64`, `chain_accumulator_in_i: Field`. +- Global: `current_epoch: u64`, `pre_active_root: Field`, `post_active_root: Field`, `pre_leaf_count: u64` (in addition to the parent's output commitments and `commitment_root`). + +`nullifier_active_i` is public and emitted in the `Transfer` / `Withdraw` event so replicas can rebuild the active indexed tree from the event log. `commitment_in_i` is kept private (below) for unlinkability, matching the parent: only the nullifier becomes the public spend artifact, never the input commitment. New private inputs: +- Per input note `i`: `commitment_in_i: Field`, proven a member of the commitment tree against `commitment_root`. - Per input note `i`: `chain_proof_i.{vk, proof, public_inputs}`. -- Per input note `i`: `active_insertion_i.{low_leaf, low_leaf_index, low_leaf_path, new_leaf_index}`, the sorted-low-leaf insertion witness against the active nullifier tree. +- Per input note `i`: `active_insertion_i.{low_leaf, low_leaf_index, low_leaf_path, new_leaf_path}`, the indexed-tree insertion witness. `low_leaf_path` authenticates the predecessor at `low_leaf_index`; `new_leaf_path` authenticates the (empty) append slot. The append index is not a free input: it is fixed to `pre_leaf_count + (i - 1)` by the constraints below. New constraints (per input note `i`): @@ -341,28 +349,26 @@ New constraints (per input note `i`): 4. `chain_proof_i.public_inputs.epoch_created == epoch_created_in_i` 5. `chain_proof_i.public_inputs.accumulator == chain_accumulator_in_i` 6. `chain_proof_i.public_inputs.epoch_validated_through == current_epoch` (for a fresh note this holds via the base case). -7. `nullifier_active_i = poseidon(commitment_in_i, spending_key, current_epoch)` (replaces parent's `poseidon2(commitment, spending_key)`). +7. `nullifier_active_i == poseidon(commitment_in_i, spending_key, current_epoch)` (replaces parent's `poseidon2(commitment, spending_key)`); the result is the public input of the same name and is emitted in the event. New constraint (per output note `j`): 8. `commitment_out_j == poseidon(token_out_j, amount_out_j, owner_out_j, salt_out_j, current_epoch)` (outputs minted with `epoch_created == current_epoch`). -Active-tree insertion chain (single pass threading the root through all `k` input nullifiers): - -Let `r_0 = pre_active_root`. For each input `i = 1..k`: +Active-tree insertion chain (single pass threading the root through all `k` input nullifiers). Let `r_0 = pre_active_root`. For each input `i = 1..k`, with canonical append index `new_leaf_index = pre_leaf_count + (i - 1)`: -- Sorted-low-leaf non-membership: `active_insertion_i.low_leaf.value < nullifier_active_i < active_insertion_i.low_leaf.next_value`, and `low_leaf` at `low_leaf_index` with `low_leaf_path` reconstructs to `r_{i-1}`. -- Pointer update of the predecessor: `low_leaf_updated = (value = low_leaf.value, next_value = nullifier_active_i, next_index = new_leaf_index)`. -- New leaf insertion: `new_leaf = (value = nullifier_active_i, next_value = low_leaf.next_value, next_index = low_leaf.next_index)` placed at `new_leaf_index`. -- `r_i =` root recomputed from `r_{i-1}` with both leaf updates applied along the supplied path. +1. Predecessor membership and non-membership: `low_leaf` at `low_leaf_index` with `low_leaf_path` reconstructs to `r_{i-1}`, and `low_leaf.value < nullifier_active_i < low_leaf.next_value`. +2. Mutate predecessor: `low_leaf_updated = (value = low_leaf.value, next_value = nullifier_active_i, next_index = new_leaf_index)`. Recompute the intermediate root `r'` from `r_{i-1}` along `low_leaf_path`. +3. Empty-slot check: `new_leaf_path` proves the slot at `new_leaf_index` holds the empty leaf in `r'`. This prevents overwriting an existing nullifier leaf. +4. Write new leaf: `new_leaf = (value = nullifier_active_i, next_value = low_leaf.next_value, next_index = low_leaf.next_index)` at `new_leaf_index`. Recompute `r_i` from `r'` along `new_leaf_path`. -Final constraint: `r_k == post_active_root`. +Final constraints: `r_k == post_active_root` and the append range is exactly `[pre_leaf_count, pre_leaf_count + k - 1]` (the contract sets `activeLeafCount += k`). -Double-spend prevention follows: any prior occurrence of `nullifier_active_i` in the active tree (from a past spend, or from an earlier input in the same tx) breaks the strict `low_leaf.value < nullifier_active_i < low_leaf.next_value` inequality and makes the proof unsatisfiable. +Two leaves change per insertion (predecessor and new slot), so each step carries two paths and is applied in sequence: the predecessor mutation produces `r'`, and the new-leaf write is proven against `r'`, since the two positions may share internal nodes. Double-spend prevention follows from step 1: any prior occurrence of `nullifier_active_i` in the active tree (from a past spend, or an earlier input in the same tx) breaks the strict `low_leaf.value < nullifier_active_i < low_leaf.next_value` inequality and makes the proof unsatisfiable. Input commitment reconstruction (changed from parent): each input note's commitment is reconstructed inside the circuit as `commitment_in_i == poseidon(token_in_i, amount_in_i, owner_in_i, salt_in_i, epoch_created_in_i)`, gaining `epoch_created_in_i` over the parent's four-field preimage, and the commitment-tree membership witness is verified against this reconstructed value. The membership-proof shape (Merkle path against `commitment_root`) is unchanged; only the leaf preimage differs. Value preservation and token consistency are unchanged from the parent SPEC. -Contract-side public-input checks: `chain_accumulator_in_i == expectedChainAccumulator(epoch_created_in_i)` per input, `current_epoch == self.currentEpoch`, `pre_active_root == self.activeNullifierRoot`. On success: `self.activeNullifierRoot = post_active_root`. +Contract-side public-input checks: `chain_accumulator_in_i == expectedChainAccumulator(epoch_created_in_i)` per input, `current_epoch == self.currentEpoch`, `pre_active_root == self.activeNullifierRoot`, `pre_leaf_count == self.activeLeafCount`. On success: `self.activeNullifierRoot = post_active_root` and `self.activeLeafCount += k`. --- @@ -375,7 +381,7 @@ Contract-side public-input checks: `chain_accumulator_in_i == expectedChainAccum | Malicious PIR / state-replica server | Sees query traffic; MAY serve incorrect nodes | InsPIRe single-server malicious-server model for privacy; every returned node is re-checked against a light-client-verified root inside a circuit | | Oblivious-sync server profiling a wallet | Receives phantom nullifiers `η_e` in clear; MAY profile a wallet's note ages and portfolio size, and across colluding servers link the same note across providers | Phantoms have no on-chain footprint, so the public spend stays unlinkable to these queries; the leak is per-wallet metadata to the serving party only. PoC accepts this; production SHOULD use oblivious nullifier derivation and synchronization (Bowe and Miers 2025/2031) so the service stays oblivious. See Limitations. | | Untrusted RPC for root reads | MAY misreport `commitment_root` or `frozenNullifierRoots[e]` | Roots MUST be read through a light client verifying storage proofs against consensus | -| Malicious wallet attempting cross-epoch double-spend | Holds spending key; MAY spend the same note in two epochs or forge a chain against fabricated roots | `epoch_created` bound into commitment; spend circuit enforces `chain.epoch_validated_through == current_epoch`; contract enforces `accumulator == expectedChainAccumulator(epoch_created)`; chain VK constrained to `FixedVK` | +| Malicious wallet attempting cross-epoch double-spend | Holds spending key; MAY spend the same note in two epochs, forge a chain against fabricated roots, or build the chain under a fabricated spending key | `epoch_created` bound into commitment; spend circuit enforces `chain.epoch_validated_through == current_epoch`; contract enforces `accumulator == expectedChainAccumulator(epoch_created)`; chain VK constrained to `FixedVK`; chain-update circuit binds `spending_key` to the public `commitment` via `owner_pubkey == poseidon(spending_key)`, so phantom non-membership cannot be proven under a fake key | | Network observer | Sees IP, timing, size of PIR sessions | Out of scope; production SHOULD use Tor or batched windows | The parent threat model (public observer, malicious relayer, compromised viewing key, malicious compliance authority) is unchanged. From 391cab03712d18be75727fa7c73832397ea35c57 Mon Sep 17 00:00:00 2001 From: Meyanis95 Date: Thu, 28 May 2026 15:56:07 -0400 Subject: [PATCH 08/10] docs(shielded-pool-extension): address SPEC audit findings --- .../shielded-pool-extension/SPEC.md | 113 ++++++++++++------ 1 file changed, 78 insertions(+), 35 deletions(-) diff --git a/pocs/private-payment/shielded-pool-extension/SPEC.md b/pocs/private-payment/shielded-pool-extension/SPEC.md index 9ed74a6..a0fb470 100644 --- a/pocs/private-payment/shielded-pool-extension/SPEC.md +++ b/pocs/private-payment/shielded-pool-extension/SPEC.md @@ -22,7 +22,9 @@ This extension addresses both. PIR over the pre-spend tree reads hides which lea A per-note recursive chain proof (IVC) keeps per-spend work bounded: the wallet extends it one step per rollover, and the spend circuit recursively verifies one chain proof instead of inlining `k` non-membership checks. The commitment binds `epoch_created` so the verifier can enforce that the chain covers the note's full lifetime. Deposit and attestation flows are otherwise unchanged. -Non-membership witnesses for unspent (phantom) epochs carry no on-chain footprint, so they are served in clear; PIR is reserved for the commitment-path read and the single current-epoch nullifier lookup. This relaxes full oblivious synchronization in exchange for far fewer private queries per spend (see Off-Chain State-Replica Server and Limitations). +Non-membership witnesses for unspent (phantom) epochs carry no on-chain footprint, so they are served in clear; PIR is reserved for the commitment-path read. This relaxes full oblivious synchronization in exchange for far fewer private queries per spend (see Off-Chain State-Replica Server and Limitations). + +The spend itself is split across two proofs produced by different parties: a spend proof from the wallet (commitment membership, chain-proof recursion, nullifier derivation) and an insertion proof from the relayer (advancing the active-tree root). The public nullifier list binds them on-chain. --- @@ -51,12 +53,13 @@ Reference instantiation (non-normative): Noir (`std::verify_proof`) with the Azt | `Nullifier` derivation | Extended: `η_e = poseidon(commitment, spending_key, epoch_id)` | | Attestation registry and flows | Unchanged | | `Deposit` flow | Extended: circuit enforces `epoch_created == currentEpoch` on the new commitment | -| `Private Transfer` flow | Extended: commitment-tree path read via PIR; spend circuit recursively verifies one per-input-note chain proof (no `k`-fold non-membership inside the spend circuit) | -| `Withdraw` flow | Extended: same PIR path read and recursive chain-proof verification as `Transfer` | +| `Private Transfer` flow | Extended: commitment-tree path read via PIR; spend circuit recursively verifies one per-input-note chain proof and emits public `η_active_i`; a separate relayer-produced insertion proof advances `activeNullifierRoot` and `activeLeafCount` | +| `Withdraw` flow | Extended: same PIR path read, chain-proof verification, and relayer-produced insertion proof as `Transfer` | | Commitment tree retrieval | Alternate fetch path (PIR-served raw nodes) | | Historical root retrieval | Light-client-verified (replaces implicit trusted RPC) | -| `ShieldedPool` on-chain state | Extended: `currentEpoch`, `frozenNullifierRoots`, `activeNullifierRoot`, `rolloverEpoch()`; spend tx supplies per-input-note `epoch_created` (so the contract recomputes the expected chain accumulator) and `(pre_active_root, post_active_root)` (so the contract advances the active-tree root) | +| `ShieldedPool` on-chain state | Extended: `currentEpoch`, `frozenNullifierRoots`, `activeNullifierRoot`, `activeLeafCount`, `rolloverEpoch()`; spend tx supplies per-input-note `epoch_created`; the relayer's insertion proof supplies `(pre_active_root, post_active_root, pre_leaf_count)` and the contract advances the active tree | | Per-note chain proof | New: maintained off-chain by the wallet; extended one step per epoch rollover (or `k` steps at wake-up after offline periods) | +| Insertion proof | New: per-spend proof produced by the relayer; proves the indexed-tree insertion of the public `η_active_i` list; no spending-key data | --- @@ -69,9 +72,10 @@ Reference instantiation (non-normative): Noir (`std::verify_proof`) with the Azt | Root authenticity | Light client (e.g. Helios) verifies `commitment_root` and `frozenNullifierRoots[e]` against consensus; paths are reconstructed against these roots in wallet and circuit | | Nullifier bloat | Coarse epoch-based nullifiers; cross-epoch non-membership folded into a per-note recursive chain proof (IVC), verified once by the spend circuit regardless of note age | | Spend-time per-note coverage | Commitment binds `epoch_created`, letting the verifier enforce chain coverage over the note's full lifetime | -| Phantom-epoch query privacy | Nullifiers for unspent (phantom) epochs never touch the chain, so their non-membership witnesses are served in clear; PIR is reserved for the commitment read and the single current-epoch low-leaf lookup (whose nullifier becomes the on-chain spend artifact) | +| Phantom-epoch query privacy | Nullifiers for unspent (phantom) epochs never touch the chain, so their non-membership witnesses are served in clear; PIR is reserved for the commitment read | +| Active-tree update decoupling | Spend circuit emits the public `η_active_i` list; a separate insertion proof produced by the relayer (or state-replica) advances `activeNullifierRoot` and `activeLeafCount`. The contract binds the two proofs by matching their η lists | -Artifact roles: inner (chain-update, consumed recursively) and outer (spend, verified on-chain). PIR parameters are inherited from InsPIRe. +Artifact roles: inner (chain-update, consumed recursively by the spend proof), outer spend (verified on-chain), outer insertion (verified on-chain). PIR parameters are inherited from InsPIRe. --- @@ -152,7 +156,7 @@ function rolloverEpoch() external onlyOwner; function expectedChainAccumulator(uint64 epochCreated) external view returns (bytes32); ``` -The active-epoch nullifier set is an indexed Merkle tree: leaves are sorted by value and each leaf carries a `(next_value, next_index)` pointer to the next-larger leaf. Absence of η is proven with a `low_leaf` where `low_leaf.value < η < low_leaf.next_value`. Inserting η mutates two leaves: the predecessor (its `next_value`/`next_index` repoint to η) and a freshly written leaf at the next free slot. The spend circuit performs the insertion in-circuit (see Spend Circuit diff), so the contract sees only `(pre_active_root, post_active_root)` plus the leaf count and accepts the transition by verifying the spend proof. A valid sorted-low-leaf insertion is itself a non-membership proof of η in the prior tree, so no separate `activeNullifiers` mapping or uniqueness check is needed. On-chain active-tree state is one `bytes32` and one counter; per-leaf state is held off-chain and reconstructible from event logs. +The active-epoch nullifier set is an indexed Merkle tree: leaves are sorted by value and each leaf carries a `(next_value, next_index)` pointer to the next-larger leaf. Absence of η is proven with a `low_leaf` where `low_leaf.value < η < low_leaf.next_value`. Inserting η mutates two leaves: the predecessor (its `next_value`/`next_index` repoint to η) and a freshly written leaf at the next free slot. The relayer's insertion proof performs these insertions (see Insertion Circuit), so the contract sees only `(pre_active_root, post_active_root, pre_leaf_count)` and accepts the transition by verifying that proof. A valid sorted-low-leaf insertion is itself a non-membership proof of η in the prior tree, so no separate `activeNullifiers` mapping or uniqueness check is needed. On-chain active-tree state is one `bytes32` and one counter; per-leaf state is held off-chain and reconstructible from event logs. Canonical append. New leaves are placed at sequential indices starting from `activeLeafCount`, so the post-root is a deterministic function of the inserted-nullifier sequence. A replica replaying the emitted nullifiers in block-then-input order reproduces the identical tree and root. The circuit enforces both the sequential index and that the target slot was empty, so a spend cannot overwrite an existing nullifier leaf. @@ -160,7 +164,7 @@ On `rolloverEpoch()`: `frozenNullifierRoots[currentEpoch] = activeNullifierRoot` Epoch cadence. The protocol does not impose an epoch duration; one epoch is whatever period elapses between two `rolloverEpoch()` calls. The PoC targets one rollover per month. Production deployments SHOULD pin a fixed cadence (e.g. one rollover every `N` blocks, or one per calendar period) so wallets and replicas can plan capacity. Coarse cadence directly bounds `k`, which sets both the per-spend on-chain accumulator hashing cost and the maximum chain-update work a wallet pays after an offline period. -On every `transfer` / `withdraw`: read each input note's `epoch_created` from public inputs, revert if `accumulator != expectedChainAccumulator(epoch_created)`, revert if `pre_active_root != activeNullifierRoot` or `pre_leaf_count != activeLeafCount`, then set `activeNullifierRoot = post_active_root` and `activeLeafCount += k`. The spend proof internally chains `k` sorted-low-leaf insertions (one per input nullifier) from `pre_active_root` to `post_active_root`, appending at indices `pre_leaf_count .. pre_leaf_count + k - 1`; a repeated η across two inputs of the same tx fails the second insertion because the low-leaf check would no longer be strict. +On every `transfer` / `withdraw`: verify both proofs (spend + insertion), require their ordered `η_active_1..k` lists are identical, read each input note's `epoch_created` from the spend proof's public inputs, revert if `accumulator != expectedChainAccumulator(epoch_created)` for any input, revert if `pre_active_root != activeNullifierRoot` or `pre_leaf_count != activeLeafCount`, then set `activeNullifierRoot = post_active_root` and `activeLeafCount += k`. The insertion proof internally chains `k` sorted-low-leaf insertions (one per input nullifier) from `pre_active_root` to `post_active_root`, appending at indices `pre_leaf_count .. pre_leaf_count + k - 1`; a repeated η across two inputs of the same tx fails the second insertion because the low-leaf check would no longer be strict. ```solidity function expectedChainAccumulator(uint64 epochCreated) public view returns (bytes32) { @@ -172,7 +176,7 @@ function expectedChainAccumulator(uint64 epochCreated) public view returns (byte } ``` -Active-epoch spends are caught by the in-circuit indexed-tree insertion: any prior occurrence of η in the active tree makes the sorted-low-leaf proof unsatisfiable, so no separate uniqueness check is needed. On-chain gas per spend: `O(currentEpoch - epochCreated)` SLOADs and Poseidon hashes in `expectedChainAccumulator`, plus a constant-size pre/post-root comparison and root write; bounded by coarse epochs (monthly target). +Active-epoch double-spend is caught by the insertion proof's sorted-low-leaf step: any prior occurrence of η in the active tree makes the strict `low_leaf.value < η < low_leaf.next_value` inequality unsatisfiable, so no separate uniqueness check is needed. On-chain gas per spend: `O(currentEpoch - epochCreated)` SLOADs and Poseidon hashes in `expectedChainAccumulator`, plus the verification cost of both proofs and the constant-size root/counter comparison and write; bounded by coarse epochs (monthly target). --- @@ -183,16 +187,26 @@ One server replicates public on-chain state and exposes a PIR endpoint. Hosted d | Tree | Construction | Source | Used for | |---|---|---|---| | Commitment tree | LeanIMT | `Deposit`, `Transfer` events | Membership witness of input notes (leaf-index known to wallet from its own minting event) | -| Active nullifier tree (current epoch) | Indexed Merkle tree | `Transfer`, `Withdraw` events since the last `EpochRollover` | Sorted-low-leaf insertion witness for the spend circuit's in-circuit active-tree update; PIR-served (current-epoch nullifier becomes the on-chain spend artifact) | +| Active nullifier tree (current epoch) | Indexed Merkle tree | `Transfer`, `Withdraw` events since the last `EpochRollover` | Consumed by the insertion-proof prover (typically the relayer); the spender does not query it | | Frozen nullifier tree (per past epoch `e`) | Indexed Merkle tree | `Transfer`, `Withdraw`, `EpochRollover` events | Sorted-low-leaf non-membership witness for the chain-update circuit; served in clear (phantom nullifiers have no on-chain footprint) | The contract retains only roots (one live `activeNullifierRoot` plus one `frozenNullifierRoots[e]` per past epoch), so the server reconstructs every tree from event logs and offers them as a shared service. -Query model, phantom vs current epoch. A note's per-epoch nullifiers `η_e = poseidon(commitment, spending_key, e)` for `e` in `[epoch_created, currentEpoch - 1]` are phantoms: the note was not spent in those epochs, so these values never appear on-chain. With no on-chain footprint to correlate against, the wallet sends a phantom to the server in clear and receives the sorted-low-leaf non-membership witness; correctness is re-checked in-circuit against the light-client root, so a malicious witness cannot produce a valid spend. No PIR and no local index are needed for phantom epochs. +Query model. The wallet makes two kinds of queries to the server per spend: + +- Commitment membership (PIR'd). The wallet knows its own commitment's leaf-index from its minting event and issues one index-addressed PIR query for the membership path. The server learns nothing about which commitment is being spent. +- Phantom-epoch non-membership (in clear). A note's per-epoch nullifiers `η_e = poseidon(commitment, spending_key, e)` for `e` in `[epoch_created, currentEpoch - 1]` are phantoms: the note was not spent in those epochs, so these values never appear on-chain. With no on-chain footprint to correlate against, the wallet sends each phantom to the server in clear and receives the sorted-low-leaf non-membership witness. Correctness is re-checked in the chain-update circuit against the light-client root, so a malicious witness cannot produce a valid chain extension. + +The wallet does not query the active nullifier tree. The current-epoch nullifier `η_{currentEpoch}` is consumed downstream by the relayer's insertion proof, not by the spender. + +Serving modes for frozen epochs. Frozen-epoch data is public and fully reconstructible from event logs, so the server is a convenience rather than a trust root, and the wallet MAY obtain each phantom non-membership witness either way: + +- Query (default): send the phantom `η_e` to the server and receive its sorted-low-leaf witness. Bandwidth is `O(1)` per epoch, but the server learns the phantom (the metadata leak documented in the Threat Model and Limitations). +- Static file: download the sealed leaf set for the frozen epoch (immutable, hence cacheable via a CDN or IPFS), then locate the low-leaf and reconstruct the path entirely client-side. This reveals nothing to the server but costs `O(epoch size)` bandwidth per frozen epoch. -The current-epoch nullifier `η_{currentEpoch}` is the exception: it becomes the on-chain spend artifact, so revealing it or its low-leaf neighbourhood before the transaction lands would let the server link the querying client to the spend. So the current-epoch low-leaf lookup stays private. The wallet keeps a local sorted `(value, leaf_index)` index of the current epoch's active tree (rebuilt from events since the last rollover), finds the predecessor leaf-index locally, then issues one index-addressed PIR query for that leaf and its sibling path. The commitment-membership read is PIR'd the same way, with the leaf-index known from the note's minting event. PIR is therefore consulted in exactly two places per spend; the `k - 1` phantom witnesses are served in clear. +The static-file mode suits short epochs or small deployments; at the Visa-scale monthly target a single epoch is hundreds of GB of leaf data, so neither mode removes the need for oblivious synchronization at scale (see Limitations). -The server is untrusted for correctness. Returned nodes are reassembled client-side into a root and compared against the light-client-verified on-chain root; both circuits re-check the same reconstructions. +The server is untrusted for correctness. Returned nodes are reassembled client-side into a root and compared against the light-client-verified on-chain root; the circuits re-check the same reconstructions. Root-fetch scheduling. Wallets MUST issue `eth_getProof` for `commitment_root` and any newly-emitted `frozenNullifierRoots[e]` on a fixed schedule, independent of intent to spend: poll `commitment_root` every `T` blocks and pull `frozenNullifierRoots[e]` immediately on observing each `EpochRollover(e)` event. The RPC therefore sees a uniform stream of queries shared by every active wallet, and cannot link a fetch to an imminent spend. Privacy reduces to k-anonymity over the active user base; the only metadata that remains is "this client is a ShieldedPool user," which is already public for anyone who has ever deposited or withdrawn. @@ -227,7 +241,7 @@ assert value == v Run once per `commitment_root` consumed at spend time, and once per `frozenNullifierRoots[e]` consumed during chain extension. -Frozen nullifier trees are served in clear (no PIR), so no PIR preprocessing applies to them. The active nullifier tree and the commitment tree are PIR-served and mutate continuously; preprocessing amortization across those mutations is out of scope. +Frozen nullifier trees are served in clear (no PIR), so no PIR preprocessing applies to them. The active nullifier tree is not PIR-served either: the spender never queries it, and the relayer builds insertion witnesses from its own replica. Only the commitment tree is PIR-served, and it mutates continuously (append-only); preprocessing amortization across those mutations is out of scope. --- @@ -252,12 +266,13 @@ On note creation (via `Deposit` or as a `Transfer` output), the owner generates 1. Catch up each input note's `ChainProof` to `epoch_validated_through == currentEpoch` via Chain Maintenance (phantom-epoch witnesses fetched in clear). Notes created in the current epoch already satisfy this via their genesis proof. 2. PIR-fetch `log(N)` sibling nodes per input commitment, reconstruct the root, and assert equality with the light-client-verified `commitment_root`. -3. For each input note's `η_{currentEpoch}`, locate the predecessor leaf-index in the wallet's local sorted index of the current epoch's active tree and PIR-fetch the predecessor leaf and its sibling path. This is the only private nullifier query, since `η_{currentEpoch}` becomes the on-chain spend artifact. The wallet then composes `k` sorted-low-leaf insertion witnesses into a chain advancing from `pre_active_root` (the on-chain current root) to `post_active_root`. -4. Run the spend circuit with per-input chain proofs, commitment-tree membership witnesses, the chained active-tree insertion witnesses, spending key, and output note data (`epoch_created = currentEpoch`). -5. Submit via relayer with the proof and public inputs: per-input `(nullifier_active, epoch_created, accumulator)` triples plus the global `(pre_active_root, post_active_root, pre_leaf_count, current_epoch)`. Input commitments stay private. -6. Contract verifies the proof, checks `accumulator == expectedChainAccumulator(epoch_created)` per input and `pre_active_root == activeNullifierRoot`, sets `activeNullifierRoot = post_active_root`, inserts output commitments, emits `Transfer` carrying the per-input `nullifier_active` values so replicas can rebuild the active indexed tree. +3. Run the spend circuit with per-input chain proofs, commitment-tree membership witnesses, spending key, and output note data (`epoch_created = currentEpoch`). The circuit emits per-input `(η_active_i, epoch_created_in_i, chain_accumulator_in_i)` publicly. +4. Send the spend proof and its public inputs to the relayer. +5. Relayer reads its replica of the active nullifier tree, builds the per-input sorted-low-leaf insertion witnesses, and produces the insertion proof with public inputs `(pre_active_root, post_active_root, pre_leaf_count, η_active_1..k)`. +6. Relayer submits both proofs and their public inputs to the contract. +7. Contract verifies both proofs, asserts their ordered `η_active_1..k` lists are identical, checks `accumulator_i == expectedChainAccumulator(epoch_created_i)` per input and `pre_active_root == activeNullifierRoot` and `pre_leaf_count == activeLeafCount`, sets `activeNullifierRoot = post_active_root` and `activeLeafCount += k`, inserts output commitments, emits `Transfer` carrying the per-input `η_active` values so replicas can rebuild the active indexed tree. -PIR is used only for the commitment read (step 2) and the current-epoch low-leaf lookup (step 3); phantom-epoch witnesses (step 1) are served in clear. The server is never trusted for roots: every returned node is re-checked in-circuit against the light-client root. +PIR is used only for the commitment read (step 2); phantom-epoch witnesses (step 1) are served in clear. The server is never trusted for roots: every returned node is re-checked in-circuit against the light-client root. ### Withdraw (extended) @@ -271,7 +286,7 @@ The operator (PoC: contract owner) calls `rolloverEpoch()`. The contract reads t ## Circuit Constraints (diff) -One new circuit (chain-update) plus diffs to the parent Deposit / Transfer / Withdraw circuits. +Two new circuits (chain-update, insertion) plus diffs to the parent Deposit / Transfer / Withdraw circuits. ### Deposit Circuit (diff) @@ -320,18 +335,18 @@ Let `e_prev = prior_chain.public_inputs.epoch_validated_through`. - `accumulator == poseidon2(prior_chain.public_inputs.accumulator, frozen_root_next)` - Key binding: `owner_pubkey == poseidon(spending_key)` and `commitment == poseidon(token, amount, owner_pubkey, salt, epoch_created)`. The public `commitment` thus pins `spending_key` to the note's real owner key, so the nullifier below is the one that would actually have been published if the note were spent in `e_prev`. Without this, a prover could supply a fabricated `spending_key`, derive a phantom η that is trivially absent, and pass non-membership while the real nullifier sits in the frozen tree. - `η = poseidon(commitment, spending_key, e_prev)` - - Sorted-low-leaf check: `low_leaf < η < low_leaf_next_value`, and the supplied Merkle path with `low_leaf` at `leaf_index` reconstructs to `frozen_root_next`. + - Sorted-low-leaf check: `low_leaf.value < η < low_leaf_next_value`, and the supplied Merkle path with `low_leaf` at `leaf_index` reconstructs to `frozen_root_next`. Branch soundness: `is_base_case` is private but constraint set #1 forces `epoch_validated_through == epoch_created` and `accumulator == 0`. A spender cannot fake the base case for a note with `epoch_created < currentEpoch` because the spend circuit enforces `chain.epoch_validated_through == currentEpoch` and the on-chain accumulator check binds to real frozen roots. ### Spend Circuit (Transfer / Withdraw, diff) -Outer artifact, verified on-chain. MUST be zero-knowledge. +Outer artifact, verified on-chain. MUST be zero-knowledge. Produced by the wallet. Performs no active-tree work: it only proves commitment membership, chain-proof recursion, and nullifier derivation. New public inputs: - Per input note `i`: `nullifier_active_i: Field`, `epoch_created_in_i: u64`, `chain_accumulator_in_i: Field`. -- Global: `current_epoch: u64`, `pre_active_root: Field`, `post_active_root: Field`, `pre_leaf_count: u64` (in addition to the parent's output commitments and `commitment_root`). +- Global: `current_epoch: u64` (in addition to the parent's output commitments and `commitment_root`). `nullifier_active_i` is public and emitted in the `Transfer` / `Withdraw` event so replicas can rebuild the active indexed tree from the event log. `commitment_in_i` is kept private (below) for unlinkability, matching the parent: only the nullifier becomes the public spend artifact, never the input commitment. @@ -339,7 +354,6 @@ New private inputs: - Per input note `i`: `commitment_in_i: Field`, proven a member of the commitment tree against `commitment_root`. - Per input note `i`: `chain_proof_i.{vk, proof, public_inputs}`. -- Per input note `i`: `active_insertion_i.{low_leaf, low_leaf_index, low_leaf_path, new_leaf_path}`, the indexed-tree insertion witness. `low_leaf_path` authenticates the predecessor at `low_leaf_index`; `new_leaf_path` authenticates the (empty) append slot. The append index is not a free input: it is fixed to `pre_leaf_count + (i - 1)` by the constraints below. New constraints (per input note `i`): @@ -355,20 +369,44 @@ New constraint (per output note `j`): 8. `commitment_out_j == poseidon(token_out_j, amount_out_j, owner_out_j, salt_out_j, current_epoch)` (outputs minted with `epoch_created == current_epoch`). -Active-tree insertion chain (single pass threading the root through all `k` input nullifiers). Let `r_0 = pre_active_root`. For each input `i = 1..k`, with canonical append index `new_leaf_index = pre_leaf_count + (i - 1)`: +Input commitment reconstruction (changed from parent): each input note's commitment is reconstructed inside the circuit as `commitment_in_i == poseidon(token_in_i, amount_in_i, owner_in_i, salt_in_i, epoch_created_in_i)`, gaining `epoch_created_in_i` over the parent's four-field preimage, and the commitment-tree membership witness is verified against this reconstructed value. The membership-proof shape (Merkle path against `commitment_root`) is unchanged; only the leaf preimage differs. Value preservation and token consistency are unchanged from the parent SPEC. + +Contract-side public-input checks for this proof: `commitment_root` is validated against the contract's known commitment-tree root(s) exactly as in the parent (the PIR read path changes how the wallet fetches the path, not how the contract pins the root), `chain_accumulator_in_i == expectedChainAccumulator(epoch_created_in_i)` per input, and `current_epoch == self.currentEpoch`. + +### Insertion Circuit (new) + +Outer artifact, verified on-chain. Need not be zero-knowledge: all inputs are public state. Produced by the relayer (or any party running the state-replica, which already maintains the active-tree leaves). + +Public inputs: + +- `pre_active_root: Field` +- `post_active_root: Field` +- `pre_leaf_count: u64` +- Ordered list `nullifier_active_1..k: Field`, matching the spend proof's per-input emission order. + +Private inputs, per insertion `i in 1..k`: + +- `low_leaf: { value, next_value, next_index }` +- `low_leaf_index: u64` +- `low_leaf_path: Field[]`, the sibling path authenticating `low_leaf` at `low_leaf_index`. +- `new_leaf_path: Field[]`, the sibling path authenticating the (empty) slot at the append index. + +Constraints: an insertion chain threading the root through all `k` nullifiers. Let `r_0 = pre_active_root`. For each input `i = 1..k`, with canonical append index `new_leaf_index = pre_leaf_count + (i - 1)`: 1. Predecessor membership and non-membership: `low_leaf` at `low_leaf_index` with `low_leaf_path` reconstructs to `r_{i-1}`, and `low_leaf.value < nullifier_active_i < low_leaf.next_value`. 2. Mutate predecessor: `low_leaf_updated = (value = low_leaf.value, next_value = nullifier_active_i, next_index = new_leaf_index)`. Recompute the intermediate root `r'` from `r_{i-1}` along `low_leaf_path`. 3. Empty-slot check: `new_leaf_path` proves the slot at `new_leaf_index` holds the empty leaf in `r'`. This prevents overwriting an existing nullifier leaf. 4. Write new leaf: `new_leaf = (value = nullifier_active_i, next_value = low_leaf.next_value, next_index = low_leaf.next_index)` at `new_leaf_index`. Recompute `r_i` from `r'` along `new_leaf_path`. -Final constraints: `r_k == post_active_root` and the append range is exactly `[pre_leaf_count, pre_leaf_count + k - 1]` (the contract sets `activeLeafCount += k`). +Final constraint: `r_k == post_active_root`. Two leaves change per insertion (predecessor and new slot), so each step carries two paths and is applied in sequence: the predecessor mutation produces `r'`, and the new-leaf write is proven against `r'`, since the two positions may share internal nodes. Double-spend prevention follows from step 1: any prior occurrence of `nullifier_active_i` in the active tree (from a past spend, or an earlier input in the same tx) breaks the strict `low_leaf.value < nullifier_active_i < low_leaf.next_value` inequality and makes the proof unsatisfiable. -Input commitment reconstruction (changed from parent): each input note's commitment is reconstructed inside the circuit as `commitment_in_i == poseidon(token_in_i, amount_in_i, owner_in_i, salt_in_i, epoch_created_in_i)`, gaining `epoch_created_in_i` over the parent's four-field preimage, and the commitment-tree membership witness is verified against this reconstructed value. The membership-proof shape (Merkle path against `commitment_root`) is unchanged; only the leaf preimage differs. Value preservation and token consistency are unchanged from the parent SPEC. +Contract-side public-input checks for this proof: `pre_active_root == self.activeNullifierRoot`, `pre_leaf_count == self.activeLeafCount`. On success: `self.activeNullifierRoot = post_active_root`, `self.activeLeafCount += k`. + +### Cross-proof binding -Contract-side public-input checks: `chain_accumulator_in_i == expectedChainAccumulator(epoch_created_in_i)` per input, `current_epoch == self.currentEpoch`, `pre_active_root == self.activeNullifierRoot`, `pre_leaf_count == self.activeLeafCount`. On success: `self.activeNullifierRoot = post_active_root` and `self.activeLeafCount += k`. +The contract MUST assert the ordered `nullifier_active_1..k` list in the insertion proof's public inputs is identical, element-wise, to the ordered `nullifier_active_i` values emitted by the spend proof's per-input public inputs. Without this, a relayer could submit an insertion proof for a different nullifier list and corrupt the active tree's correspondence to actual spends. With it, the two proofs are glued through the public η values: the spend proof commits the spender to the η list (via the key-bound derivation), and the insertion proof commits the relayer to inserting exactly that list into the active tree. --- @@ -380,8 +418,10 @@ Contract-side public-input checks: `chain_accumulator_in_i == expectedChainAccum |---|---|---| | Malicious PIR / state-replica server | Sees query traffic; MAY serve incorrect nodes | InsPIRe single-server malicious-server model for privacy; every returned node is re-checked against a light-client-verified root inside a circuit | | Oblivious-sync server profiling a wallet | Receives phantom nullifiers `η_e` in clear; MAY profile a wallet's note ages and portfolio size, and across colluding servers link the same note across providers | Phantoms have no on-chain footprint, so the public spend stays unlinkable to these queries; the leak is per-wallet metadata to the serving party only. PoC accepts this; production SHOULD use oblivious nullifier derivation and synchronization (Bowe and Miers 2025/2031) so the service stays oblivious. See Limitations. | +| Public observer correlating note age | Reads each spend's public `epoch_created_in_i`, learning the creation epoch of every spent input | `epoch_created` is published so the contract can recompute `expectedChainAccumulator`; it reveals note age only (never amount or owner), but shrinks the anonymity set for inputs drawn from sparsely-populated epochs. PoC accepts this; the shared-accumulator / on-chain-Merkle-of-frozen-roots mitigation (see Limitations) lets the circuit prove the accumulator over a contiguous frozen-root suffix without publishing `epoch_created`, closing this leak. | | Untrusted RPC for root reads | MAY misreport `commitment_root` or `frozenNullifierRoots[e]` | Roots MUST be read through a light client verifying storage proofs against consensus | -| Malicious wallet attempting cross-epoch double-spend | Holds spending key; MAY spend the same note in two epochs, forge a chain against fabricated roots, or build the chain under a fabricated spending key | `epoch_created` bound into commitment; spend circuit enforces `chain.epoch_validated_through == current_epoch`; contract enforces `accumulator == expectedChainAccumulator(epoch_created)`; chain VK constrained to `FixedVK`; chain-update circuit binds `spending_key` to the public `commitment` via `owner_pubkey == poseidon(spending_key)`, so phantom non-membership cannot be proven under a fake key | +| Malicious wallet attempting cross-epoch double-spend | Holds spending key; MAY spend the same note in two epochs, forge a chain against fabricated roots, build the chain under a fabricated spending key, or try to substitute a different `η_active` between the spend and insertion proofs | `epoch_created` bound into commitment; spend circuit enforces `chain.epoch_validated_through == current_epoch`; contract enforces `accumulator == expectedChainAccumulator(epoch_created)`; chain VK constrained to `FixedVK`; chain-update circuit binds `spending_key` to the public `commitment` via `owner_pubkey == poseidon(spending_key)`, so phantom non-membership cannot be proven under a fake key; the contract asserts the spend proof's emitted `η_active_1..k` list is identical to the insertion proof's input list, so a wallet cannot substitute a different η | +| Malicious or absent relayer for the insertion proof | MAY refuse to produce the insertion proof, or produce one whose root transition is invalid or whose η list disagrees with the spend proof | The insertion proof is verified on-chain so a wrong root or wrong η list cannot land. Refusal only delays the spend (liveness only). Mitigated by relayer competition or a permissionless prover market | | Network observer | Sees IP, timing, size of PIR sessions | Out of scope; production SHOULD use Tor or batched windows | The parent threat model (public observer, malicious relayer, compromised viewing key, malicious compliance authority) is unchanged. @@ -390,11 +430,11 @@ The parent threat model (public observer, malicious relayer, compromised viewing | Property | Description | |---|---| -| Query privacy (PIR'd reads) | For the two PIR'd reads, commitment membership and the current-epoch low-leaf lookup, the server learns nothing about the queried index beyond timing. Phantom-epoch non-membership is intentionally served in clear, so the server does learn those phantom nullifiers (see Threat Model, Limitations); this never links to the on-chain spend. | +| Query privacy (PIR'd reads) | The one PIR'd read is the commitment-membership lookup; the server learns nothing about the queried index beyond timing. Phantom-epoch non-membership is intentionally served in clear, so the server does learn those phantom nullifiers (see Threat Model, Limitations); this never links to the on-chain spend. | | Witness correctness | A malicious server cannot induce a valid spend against an incorrect path, non-membership witness, or chain proof: reconstructions are re-checked in-circuit against light-client roots, and accumulators are re-checked on-chain. | -| Cross-epoch double-spend safety | A note can be spent in at most one epoch. Range `[epoch_created, current_epoch - 1]` is bound by the commitment and on-chain accumulator check; the current epoch is covered by the in-circuit sorted-low-leaf insertion against `activeNullifierRoot`, which fails if η already appears in the active tree. | -| Bounded active state | On-chain state per epoch is one `bytes32` (`activeNullifierRoot`, overwritten on each spend, reset on rollover) plus one `bytes32` per past epoch (`frozenNullifierRoots[e]`). Per-leaf state of the active and frozen trees lives off-chain and is reconstructible from event logs, so on-chain storage does not grow per nullifier. | -| Constant-cost spend circuit (when chain is current) | Spend-circuit verification cost is independent of note age once `epoch_validated_through == currentEpoch`: one recursive chain-proof verify regardless of how many epochs the note has lived. | +| Cross-epoch double-spend safety | A note can be spent in at most one epoch. Range `[epoch_created, current_epoch - 1]` is bound by the commitment and on-chain accumulator check; the current epoch is covered by the insertion proof's sorted-low-leaf step against `activeNullifierRoot`, which fails if η already appears in the active tree. The spend proof's η list and the insertion proof's η list are pinned equal on-chain, so a wallet cannot substitute. | +| Bounded active state (nullifier side) | On-chain state per epoch is one `bytes32` (`activeNullifierRoot`, overwritten on each spend, reset on rollover) plus one `bytes32` per past epoch (`frozenNullifierRoots[e]`). Per-leaf state of the active and frozen trees lives off-chain and is reconstructible from event logs, so on-chain storage does not grow per nullifier. This bound is nullifier-scoped: the commitment tree remains append-only and unbounded (parent-owned), and the PIR database and per-query cost grow with it. | +| Constant-cost spend circuit (when chain is current) | Spend-circuit verification cost is independent of note age once `epoch_validated_through == currentEpoch`: one recursive chain-proof verify per input plus nullifier derivation, with no per-input active-tree work. | | Linear contract-side accumulator check | The on-chain check is not constant: `expectedChainAccumulator(epoch_created)` costs `O(currentEpoch - epoch_created)` SLOADs and Poseidon hashes, linear in the number of frozen epochs the note spans. Bounded in practice by coarse epochs (monthly target); see Limitations for the production mitigation (on-chain Merkle of frozen roots, or shared accumulator). | ### Limitations & Shortcuts (PoC Scope) @@ -406,8 +446,10 @@ The parent threat model (public observer, malicious relayer, compromised viewing | Wallet maintains a chain proof per held note | Per-note state plus incremental proofs at every rollover | Shared accumulator removes per-note state | | Centralized epoch rollover | Owner-only `rolloverEpoch()` is a liveness single point | Decentralized trigger | | Single state-replica server | Spend liveness depends on one replica; logs allow reconstruction so funds are safe | Multiple independent replicas | -| Active-root write contention | The active tree is advanced through a single `activeNullifierRoot`. A spend built against a `pre_active_root` that another spend has already superseded reverts, and its insertion witness (including the low-leaf predecessor) must be rebuilt against the new root. Concurrent spends therefore serialize. | Relayer/sequencer that orders spends and rebuilds witnesses, or batched multi-spend active-tree updates | +| Active-root write contention (relayer-side) | The active tree is advanced through a single `activeNullifierRoot`; the relayer's insertion proof is built against the current root and must be regenerated if another spend lands first. The spender's spend proof is state-independent and does not need to be rebuilt. | A single designated sequencer per epoch removes contention by serializing spends, but trades it for a centralization, censorship, and liveness single point (and a spend-metadata honeypot); since only the insertion proof is state-dependent, this is a deployment choice, not a protocol requirement. Production: decentralized or rotating sequencing, or batched multi-spend insertion proofs under relayer competition. | +| Insertion-proof liveness | Spends require at least one relayer willing to produce the insertion proof. Funds are safe (the proof is verified on-chain so it cannot be forged), but spend liveness depends on relayer availability. | Multiple independent relayers or a permissionless prover market | | Server-side metadata leak (in-clear phantom serving) | The state-replica server learns a wallet's phantom nullifiers, hence note ages and portfolio metadata (not its on-chain spends); colluding servers can link the same note across providers | Oblivious nullifier derivation and synchronization (Bowe and Miers 2025/2031, Project Tachyon), keeping the syncing service oblivious | +| On-chain note-age disclosure (public `epoch_created`) | Each spend publishes the creation epoch of its inputs so the contract can recompute the chain accumulator; reveals note age (not amount/owner) and reduces the anonymity set for sparse epochs | Shared accumulator / on-chain Merkle of frozen roots: the circuit proves the accumulator over a contiguous frozen-root suffix, so `epoch_created` need not be public | | Recursive prover maturity | Required capabilities are not uniformly stable across systems | Pin a version; track upstream releases | | No PIR over the encrypted note log | Note discovery requires trial decryption or operator-side filtering | FMD / OMR (future extension) | | No post-quantum primitives | Note encryption uses ECDH/AEAD as in parent | Lattice KEM and signatures | @@ -424,13 +466,14 @@ The parent threat model (public observer, malicious relayer, compromised viewing | Indexed Merkle tree | Merkle tree whose leaves are sorted by value and carry `(next_value, next_index)` pointers to the next-larger leaf; supports both sorted-low-leaf non-membership and ordered insertion proofs. The active and frozen nullifier trees use this construction. | | LeanIMT | Lean Incremental Merkle Tree (Semaphore); an append-only Merkle tree variant. The commitment tree uses this construction (membership only, no ordering needed). | | Non-membership witness | Sorted-low-leaf Merkle witness proving absence from an indexed Merkle tree. | -| State-replica server | Off-chain service hosting flattened node arrays of the commitment tree and the active/frozen nullifier trees. Answers PIR queries for the commitment-path read and the current-epoch low-leaf lookup, and serves phantom (frozen-epoch) non-membership witnesses in clear. | +| State-replica server | Off-chain service hosting flattened node arrays of the commitment tree and the active/frozen nullifier trees. Answers PIR queries for the commitment-path read, serves phantom (frozen-epoch) non-membership witnesses in clear, and (typically the same operator as the relayer) builds active-tree insertion witnesses for the insertion proof. | | Phantom nullifier | A note's per-epoch nullifier `η_e` for an epoch in which the note was not spent; never emitted on-chain, so it can be revealed to the state-replica server without linking to any on-chain transaction. | | Light client | Verifies Ethereum headers and storage proofs against consensus. | | Chain proof | Per-note off-chain proof of non-spend from `epoch_created` through `epoch_validated_through - 1`. | | IVC | Incrementally Verifiable Computation. Each invocation recursively verifies its predecessor; permits bounded-memory step-wise extension. | | Base-case sentinel | Genesis chain proof with `epoch_validated_through == epoch_created` and `accumulator == 0`. | | Chain accumulator | Running Poseidon hash binding a chain proof to the sequence of frozen roots; recomputable on-chain. | +| Insertion proof | Per-spend proof attesting that the active-tree root transition `(pre_active_root, post_active_root)` is a valid indexed-tree insertion of the public `η_active_1..k` list at canonical append indices starting from `pre_leaf_count`. Produced by the relayer; carries no spending-key data. | --- From 4bb010c50891bad9d67aea951f1c52a4122cf324 Mon Sep 17 00:00:00 2001 From: Meyanis95 Date: Mon, 1 Jun 2026 11:47:50 -0400 Subject: [PATCH 09/10] fix(shielded-pool-extension): bind owner_in to spending_key in spend circuit --- pocs/private-payment/shielded-pool-extension/SPEC.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pocs/private-payment/shielded-pool-extension/SPEC.md b/pocs/private-payment/shielded-pool-extension/SPEC.md index a0fb470..b407d30 100644 --- a/pocs/private-payment/shielded-pool-extension/SPEC.md +++ b/pocs/private-payment/shielded-pool-extension/SPEC.md @@ -146,8 +146,8 @@ mapping(uint64 => bytes32) public frozenNullifierRoots; // Active-epoch nullifier set is an indexed Merkle tree; the contract holds // only the current root and a leaf counter, advanced by each spend and reset on rollover. bytes32 public activeNullifierRoot; -uint64 public activeLeafCount; // next free slot = canonical append index -bytes32 public constant EMPTY_IMT_ROOT = /* root of an empty indexed Merkle tree, fixed at deployment */; +uint64 public activeLeafCount; // next free slot = canonical append index; starts at 1 (index 0 = genesis leaf) +bytes32 public constant EMPTY_IMT_ROOT = /* root of the genesis-leaf-only indexed Merkle tree, fixed at deployment */; /// PoC: owner-only. Production: decentralized trigger. function rolloverEpoch() external onlyOwner; @@ -156,11 +156,11 @@ function rolloverEpoch() external onlyOwner; function expectedChainAccumulator(uint64 epochCreated) external view returns (bytes32); ``` -The active-epoch nullifier set is an indexed Merkle tree: leaves are sorted by value and each leaf carries a `(next_value, next_index)` pointer to the next-larger leaf. Absence of η is proven with a `low_leaf` where `low_leaf.value < η < low_leaf.next_value`. Inserting η mutates two leaves: the predecessor (its `next_value`/`next_index` repoint to η) and a freshly written leaf at the next free slot. The relayer's insertion proof performs these insertions (see Insertion Circuit), so the contract sees only `(pre_active_root, post_active_root, pre_leaf_count)` and accepts the transition by verifying that proof. A valid sorted-low-leaf insertion is itself a non-membership proof of η in the prior tree, so no separate `activeNullifiers` mapping or uniqueness check is needed. On-chain active-tree state is one `bytes32` and one counter; per-leaf state is held off-chain and reconstructible from event logs. +The active-epoch nullifier set is an indexed Merkle tree: leaves are sorted by value and each leaf carries a `(next_value, next_index)` pointer to the next-larger leaf. Absence of η is proven with a `low_leaf` where `low_leaf.value < η < low_leaf.next_value`. Index 0 holds a genesis leaf `(0, 0, 0)`, the bootstrap low-leaf covering `[0, +inf)`, so the first insertion always has a predecessor to mutate; real nullifiers therefore occupy indices `1, 2, ...` and `activeLeafCount` starts at 1 (the genesis-leaf-only tree's root is `EMPTY_IMT_ROOT`). Inserting η mutates two leaves: the predecessor (its `next_value`/`next_index` repoint to η) and a freshly written leaf at the next free slot. The relayer's insertion proof performs these insertions (see Insertion Circuit), so the contract sees only `(pre_active_root, post_active_root, pre_leaf_count)` and accepts the transition by verifying that proof. A valid sorted-low-leaf insertion is itself a non-membership proof of η in the prior tree, so no separate `activeNullifiers` mapping or uniqueness check is needed. On-chain active-tree state is one `bytes32` and one counter; per-leaf state is held off-chain and reconstructible from event logs. Canonical append. New leaves are placed at sequential indices starting from `activeLeafCount`, so the post-root is a deterministic function of the inserted-nullifier sequence. A replica replaying the emitted nullifiers in block-then-input order reproduces the identical tree and root. The circuit enforces both the sequential index and that the target slot was empty, so a spend cannot overwrite an existing nullifier leaf. -On `rolloverEpoch()`: `frozenNullifierRoots[currentEpoch] = activeNullifierRoot`, then `activeNullifierRoot = EMPTY_IMT_ROOT`, `activeLeafCount = 0`, `currentEpoch += 1`, emit `EpochRollover(uint64 epoch, bytes32 root)`. +On `rolloverEpoch()`: `frozenNullifierRoots[currentEpoch] = activeNullifierRoot`, then `activeNullifierRoot = EMPTY_IMT_ROOT`, `activeLeafCount = 1` (reset to the genesis-leaf-only tree), `currentEpoch += 1`, emit `EpochRollover(uint64 epoch, bytes32 root)`. Epoch cadence. The protocol does not impose an epoch duration; one epoch is whatever period elapses between two `rolloverEpoch()` calls. The PoC targets one rollover per month. Production deployments SHOULD pin a fixed cadence (e.g. one rollover every `N` blocks, or one per calendar period) so wallets and replicas can plan capacity. Coarse cadence directly bounds `k`, which sets both the per-spend on-chain accumulator hashing cost and the maximum chain-update work a wallet pays after an offline period. From 37c9a22c0d4ce4949e0852d4fdd7353e4306404e Mon Sep 17 00:00:00 2001 From: Meyanis95 Date: Mon, 1 Jun 2026 12:05:44 -0400 Subject: [PATCH 10/10] feat(pir-extension): resolved rymnc comments --- pocs/private-payment/shielded-pool-extension/SPEC.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pocs/private-payment/shielded-pool-extension/SPEC.md b/pocs/private-payment/shielded-pool-extension/SPEC.md index b407d30..f327350 100644 --- a/pocs/private-payment/shielded-pool-extension/SPEC.md +++ b/pocs/private-payment/shielded-pool-extension/SPEC.md @@ -369,7 +369,7 @@ New constraint (per output note `j`): 8. `commitment_out_j == poseidon(token_out_j, amount_out_j, owner_out_j, salt_out_j, current_epoch)` (outputs minted with `epoch_created == current_epoch`). -Input commitment reconstruction (changed from parent): each input note's commitment is reconstructed inside the circuit as `commitment_in_i == poseidon(token_in_i, amount_in_i, owner_in_i, salt_in_i, epoch_created_in_i)`, gaining `epoch_created_in_i` over the parent's four-field preimage, and the commitment-tree membership witness is verified against this reconstructed value. The membership-proof shape (Merkle path against `commitment_root`) is unchanged; only the leaf preimage differs. Value preservation and token consistency are unchanged from the parent SPEC. +Input commitment reconstruction (changed from parent): each input note's commitment is reconstructed inside the circuit as `commitment_in_i == poseidon(token_in_i, amount_in_i, owner_pubkey, salt_in_i, epoch_created_in_i)`, where `owner_pubkey == poseidon(spending_key)` is the parent's inherited key binding (Transfer/Withdraw constraint 1) — the only change versus the parent is the added `epoch_created_in_i` field. The commitment-tree membership witness is verified against this reconstructed value; the membership-proof shape (Merkle path against `commitment_root`) is unchanged. Value preservation and token consistency are unchanged from the parent SPEC. Contract-side public-input checks for this proof: `commitment_root` is validated against the contract's known commitment-tree root(s) exactly as in the parent (the PIR read path changes how the wallet fetches the path, not how the contract pins the root), `chain_accumulator_in_i == expectedChainAccumulator(epoch_created_in_i)` per input, and `current_epoch == self.currentEpoch`. @@ -419,8 +419,9 @@ The contract MUST assert the ordered `nullifier_active_1..k` list in the inserti | Malicious PIR / state-replica server | Sees query traffic; MAY serve incorrect nodes | InsPIRe single-server malicious-server model for privacy; every returned node is re-checked against a light-client-verified root inside a circuit | | Oblivious-sync server profiling a wallet | Receives phantom nullifiers `η_e` in clear; MAY profile a wallet's note ages and portfolio size, and across colluding servers link the same note across providers | Phantoms have no on-chain footprint, so the public spend stays unlinkable to these queries; the leak is per-wallet metadata to the serving party only. PoC accepts this; production SHOULD use oblivious nullifier derivation and synchronization (Bowe and Miers 2025/2031) so the service stays oblivious. See Limitations. | | Public observer correlating note age | Reads each spend's public `epoch_created_in_i`, learning the creation epoch of every spent input | `epoch_created` is published so the contract can recompute `expectedChainAccumulator`; it reveals note age only (never amount or owner), but shrinks the anonymity set for inputs drawn from sparsely-populated epochs. PoC accepts this; the shared-accumulator / on-chain-Merkle-of-frozen-roots mitigation (see Limitations) lets the circuit prove the accumulator over a contiguous frozen-root suffix without publishing `epoch_created`, closing this leak. | +| Operator joining in-clear phantom queries with on-chain `epoch_created` | A party that both serves in-clear phantom queries and relays/observes the spend can join the two: the phantom batch reveals the note's `epoch_created` (its minimum) and links to the query session, and the on-chain spend republishes the same `epoch_created`. With session-to-identity linkage this can map a spend back to a KYC identity, even though the phantom `η_e` values never equal the on-chain nullifier | Requires one party (or colluders) in both roles AND default in-clear serving. Closed by either static-file serving (no in-clear phantom query) or separating the state-replica operator from the relayer; the shared-accumulator mitigation also removes public `epoch_created`, eliminating the join key. PoC documents this as a trust assumption; production SHOULD enforce role separation or oblivious sync | | Untrusted RPC for root reads | MAY misreport `commitment_root` or `frozenNullifierRoots[e]` | Roots MUST be read through a light client verifying storage proofs against consensus | -| Malicious wallet attempting cross-epoch double-spend | Holds spending key; MAY spend the same note in two epochs, forge a chain against fabricated roots, build the chain under a fabricated spending key, or try to substitute a different `η_active` between the spend and insertion proofs | `epoch_created` bound into commitment; spend circuit enforces `chain.epoch_validated_through == current_epoch`; contract enforces `accumulator == expectedChainAccumulator(epoch_created)`; chain VK constrained to `FixedVK`; chain-update circuit binds `spending_key` to the public `commitment` via `owner_pubkey == poseidon(spending_key)`, so phantom non-membership cannot be proven under a fake key; the contract asserts the spend proof's emitted `η_active_1..k` list is identical to the insertion proof's input list, so a wallet cannot substitute a different η | +| Malicious wallet attempting cross-epoch double-spend | Holds spending key; MAY spend the same note in two epochs, forge a chain against fabricated roots, build the chain under a fabricated spending key, or try to substitute a different `η_active` between the spend and insertion proofs | `epoch_created` bound into commitment; spend circuit enforces `chain.epoch_validated_through == current_epoch`; contract enforces `accumulator == expectedChainAccumulator(epoch_created)`; chain VK constrained to `FixedVK`; chain-update circuit binds `spending_key` to the public `commitment` via `owner_pubkey == poseidon(spending_key)`, so phantom non-membership cannot be proven under a fake key; the spend circuit inherits the parent's owner-key binding (`owner_pubkey == poseidon(spending_key)`), so a note yields exactly one `nullifier_active` per epoch (no multi-nullifier double-spend, and no spending a note whose key the prover lacks); the contract asserts the spend proof's emitted `η_active_1..k` list is identical to the insertion proof's input list, so a wallet cannot substitute a different η | | Malicious or absent relayer for the insertion proof | MAY refuse to produce the insertion proof, or produce one whose root transition is invalid or whose η list disagrees with the spend proof | The insertion proof is verified on-chain so a wrong root or wrong η list cannot land. Refusal only delays the spend (liveness only). Mitigated by relayer competition or a permissionless prover market | | Network observer | Sees IP, timing, size of PIR sessions | Out of scope; production SHOULD use Tor or batched windows |