Privacy Cash
EVM Pool

Compared the EtherPool and Merkle Tree with History contracts against their Solana equivalent, looking for constraint differences and EVM-specific risk.
report id
0853
category
Identity & Cryptography
language
Solidity
final report
March 16, 2026
lines of code
267
Content

Download audit report

1

Executive Summary

This document presents the security review performed by Nethermind Security for the Privacy Cash EVM smart contracts and evaluation of existing Solana ZK circuit usage on EVM from March. 10, 2026 to Mar. 12, 2026. The Ether Pool contract is a shielded pool using ZK proofs to verify the correct commitment and nullification of notes in a merkle tree.

The audited code comprises 267 lines of code written in the Solidity language. The audit included the EtherPool contract and Merkle Tree with History contract together. Additionally, the circom files were compared with the Solana equivalent files at commit 880c069bd4df95bc3921ce3b422bc1875ab7c13a for differing constraints as well as verifying the zkey, verifier contract and EVM applicability.

The audit was performed using (a) manual analysis of the codebase and (b) automated analysis tools.

Audit assessment
Audit TypeSecurity Review
Initial ReportMar 12, 2026
Final Report
Response from ClientRegular responses during audit engagement
Repositoryprivacy-cash-core-evm
Audit Commited4566d007
Final Commitf5a67581fb
Documentation-
Documentation AssessmentMedium
Total findings
3
Fixed
1
Acknowledged
2
Mitigated
0
Unresolved
0

Findings by severity

  • Critical0
  • High0
  • Medium0
  • Low0
  • Informational3
  • Best Practices0

2

Audited Files

ContractLoCCommentsRatioBlankTotal
contracts/Proxy.sol2150.0%14
contracts/MerkleTreeWithHistory.sol1191613.4%18151
contracts/EtherPool.sol14674.8%28180
TOTAL267247.2%47335

4

System Overview

4.1 Privacy Cash

Privacy Cash Core is a private Ether transaction protocol built on the EVM that implements the UTXO model with ZK proofs. Every interaction with the protocol (deposit or withdrawal) is routed through a single transact() function exposed by the EtherPool contract via a UUPS upgradeable proxy.

Basically, the sender locally constructs a Groth16 proof implemented in the Circom circuits, which attests to the validity of the input and output UTXOs without revealing amounts and owners, or the transaction graph. On-chain, the proof is verified by the Verifier2 contract and the output commitments are inserted into a Poseidon-based incremental Merkle tree (MerkleTreeWithHistory).

Privacy Cash Core — Overview
Fig. 2: Privacy Cash Core — Overview

MerkleTreeWithHistory:

The MerkleTreeWithHistory contract implements an incremental Merkle tree based on the Poseidon hash function, designed to store the commitments of UTXOs deposited into the protocol. The tree has a configurable depth (up to 31 levels) and maintains a circular history of the last 100 roots, allowing recently generated proofs to remain valid even after new insertions.

Tree Structure:

The tree is initialized with zero leaves whose precomputed hashes are hard-coded in the contract (function zeros(i)). Each level i of the tree has a canonical empty leaf value zi, where:

z0 = 0x062e4bce…, zi = Poseidon(zi-1, zi-1)

The core state variables are:

  • filledSubtrees[i]: hash of the most recent complete subtree at level i, used to compute the new root without storing the entire tree.
  • roots[k]: circular buffer of size ROOT_HISTORY_SIZE = 100 containing historical roots.
  • currentRootIndex: index of the most recent root in the buffer.
  • nextIndex: index of the next available leaf.

Pair Insertions:

The internal function _insert(leaf1, leaf2) always inserts two commitments simultaneously, corresponding to the two outputs of each JoinSplit transaction. The hash of the pair is computed at the base and propagated upward to the root:

h0 = Poseidon(leaf1, leaf2)

At each level i, hi-1 is combined either with the filled subtree on the left (if the position is odd) or with the canonical zero value zi (if it is even). The new root is stored in the circular buffer and nextIndex is incremented by 2.

Root Verification:

The function isKnownRoot(root) iterates through the circular buffer from the most recent to the oldest entry, accepting any root present in the history. This guarantees concurrency tolerance: transactions that generated their proof against a slightly outdated root are still accepted as long as the root remains within the 100-insertion window.

4.1.1 EtherPool

The EtherPool contract is the protocol entry point for private Ether transactions. It inherits from MerkleTreeWithHistory and implements the UUPS upgradeability pattern, as well as ReentrancyGuard and PausableUpgradeable from OpenZeppelin. Every operation — deposit, internal transfer, or withdrawal — is routed through a single function transact(), which validates a ZK proof before any movement of funds occurs.

UTXO Model and Cryptographic Types:

The protocol operates using UTXOs (Unspent Transaction Outputs) that never appear in plaintext on the blockchain. Each UTXO has the following logical structure (defined in the Circom circuits):

UTXO {
amount : uint248 // value in wei
pubKey : Field // Poseidon(privateKey)
blinding : Field // random blinding value
mintAddress: Field // token address
}

From the UTXO two public values are derived that appear on-chain:

Commitment: Represents the UTXO in an opaque form. It is inserted into the Merkle tree when creating an output:

commitment = Poseidon(amount, pubKey, blinding, mintAddress)

Nullifier: Invalidates a UTXO when it is consumed as an input. The circuit first computes an internal signature before deriving the nullifier:

signature = Poseidon(privateKey, commitment, merklePath)

nullifier = Poseidon(commitment, merklePath, signature)

where merklePath is the index (position) of the leaf in the tree. Binding the signature to the position ensures that the same commitment cannot be spent using different paths.

Contract Data Structures: The transact() function receives two arguments:

struct ExtData {
address recipient // withdrawal recipient
int256 extAmount // external value (+deposit / -withdrawal)
address feeRecipient // relayer address
uint256 fee // relayer fee
bytes encryptedOutput1 // encrypted output UTXO for the receiver
bytes encryptedOutput2 // encrypted output UTXO for the receiver
}

struct Proof {
uint[2] pA // Groth16 proof — G1 element
uint[2][2] pB // Groth16 proof — G2 element
uint[2] pC // Groth16 proof — G1 element
bytes32 root // Merkle tree root used in the proof
bytes32[2] inputNullifiers // nullifiers of the 2 inputs
bytes32[2] outputCommitments // commitments of the 2 outputs
uint256 publicAmount // extAmount - fee (sign encoded)
bytes32 extDataHash // hash of the external data
}

The extDataHash field binds the external data to the ZK proof:

extDataHash = keccak256(abi.encode(ExtData)) mod p

where p is the BN254 field size (FIELD_SIZE). This prevents a relayer from modifying the recipient or the fee without invalidating the proof.

4.1.1.1 The transact() function

This is the core function in the contract. It performs several checks before modifying any state:

  1. Valid root: isKnownRoot(args.root) — the root used to generate the proof must exist in the history.
  2. Unspent nullifiers: both inputNullifiers must not exist in nullifierHashes.
  3. Correct external hash: extDataHash must match the hash of the provided ExtData.
  4. Consistent public amount: publicAmount == extAmount - fee, with withdrawal encoded as p - |publicAmount|.
  5. Valid ZK proof: verifier2.verifyProof() verifies the Groth16 proof with the 7 public inputs: root, publicAmount, extDataHash, two nullifiers, and two commitments.

Administration and Upgradeability:

The contract follows the UUPS pattern. Administrator changes follow a two-step mechanism (transferAdmin + claimAdmin), preventing accidental transfers to invalid addresses. The admin can also pause/unpause the contract and reconfigure the maximum deposit limit via configureLimits().

4.2 Circuit-to-Verifier Consistency Check

To verify that the on-chain Verifier2.sol contract faithfully represents the intended zero-knowledge circuit, we performed a three-step consistency check spanning the full compilation pipeline: from the Circom source to the Solidity verifier.

4.2.1 Circuit Compilation

The circuit transaction2.circom was compiled from source using circom with the bn128 curve target, producing a fresh R1CS constraint system, a witness-generation WASM module, and a symbol file:

circom circuits/transaction2.circom \
--r1cs --wasm --sym \
-l node_modules \
-o build/circuits-test

The compiler reported the following circuit parameters:

ParameterValue
Template instances292
Non-linear constraints15,932
Linear constraints17,866
Total wires33,862
Labels50,476
Public inputs7
Private inputs67
Public outputs0

The 7 public inputs correspond to the signals explicitly declared public in transaction2.circom, instantiated as Transaction(26, 2, 2) with nIns = 2 and nOuts = 2. Each scalar signal contributes one field element, while array signals contribute one field element per entry: root (1), publicAmount (1), extDataHash (1), inputNullifier[nIns] (2), and outputCommitment[nOuts] (2), totalling 7 field elements.

4.2.2 Verify the final zkey

As a final consistency check, we verify the proving key against both the freshly compiled R1CS and the Powers of Tau transcript from the Hermez Hermez Powers of Tau transcript (powersOfTau28_hez_final_18.ptau, we used the same as used for Solana). This step verifies zkey file contributions and verify that matches with the new compiled transaction2.r1cs and ptau.

snarkjs zkey verify transaction2.r1cs \  // @audit the fresh r1cs generated from the transaction2.circom
powersOfTau28_hez_final_18.ptau \
transaction2.zkey // @audit zkey in the repository

snarkjs computed the same circuit hash as in the Trusted setup markdown from the R1CS:

95a3ebe6 51cf2567 92d2eee4 67ce4857
3c2c4f71 4644bb19 5581706b 1adf23a9
bcb7a073 e6e8ec24 0be09fec 12744f94
71a28551 5c976862 da704d3b 7898482a

This matching confirms that the key was generated from the same circuit currently present in the repository. The trusted setup recorded four contributions from different participants. The command output with a confirmation:

[INFO] snarkJS: ZKey Ok!

4.2.3 Verifier Contract Export and Diff

The Solidity verifier was re-exported from the proving key:

snarkjs zkey export solidityverifier \
transaction2.zkey Verifier2.sol

A byte-level diff between the freshly generated file and the Verifier2.sol already present in the repository revealed a single difference:

+contract Groth16Verifier {
---
-contract Verifier2 {

The only discrepancy is the contract name, which was renamed from the snarkjs default (Groth16Verifier) to Verifier2. All constants, including the α, β, γ, and δ verification key points and the 8 input commitment points IC_0 … IC_7, are identical between the two files.

4.2.4 Conclusion

The three-step check passed in full. The circuit source code compiles to an R1CS whose hash matches the one embedded in the proving key that generates a Solidity verifier identical to the one provided in the repository, except the contract name. The Verifier2.sol correctly encodes the verification key derived from transaction2.circom and its associated trusted setup.

4.3 Solana/EVM Circuit Compatibility Check

To verify that the on-chain Verifier2.sol contract and transaction2.circom circuit works equivalently to the Solana implementation in the Privacy Cash Solana Repository at commit 880c069bd4df95bc3921ce3b422bc1875ab7c13a, we performed a two-step check verifying the usage of the ZK Circuit on the EVM.

4.3.1 Circuit Compilation

As stated above, the circuit transaction2.circom was compiled from source using circom with the bn128 curve target, producing a fresh R1CS constraint system, a witness-generation WASM module, and a symbol file:

circom circuits/transaction2.circom \
--r1cs --wasm --sym \
-l node_modules \
-o build/circuits-test

4.3.2 Proof of EVM Compatibility Testing

To ensure the correctness of the Zero Knowledge Proof (ZKP) in the context of the Ethereum Virtual Machine (EVM), we designed and implemented a suite of tests. These tests were intended to thoroughly verify the ZKP's behavior in various scenarios, ensuring that it passes under correct conditions and fails as expected when there are errors or tampering. The key test cases included:

  • A valid Zero Knowledge Proof passes
  • An invalid ZKP fails
  • A valid ZKP fails if the public inputs have been tampered with

The output of running the tests was:

> privacy-cash-core-evm@1.0.0 test:verifier
> npx hardhat test test/evm-compat.test.js

No need to generate any newer typings.


Verifier
✔ Should verify a valid ZKP (1995ms)
✔ Should return false for a tampered proof (1074ms)
Tampered public signals
✔ Should return false for a valid proof with tampered Public Signal: root (1020ms)
✔ Should return false for a valid proof with tampered Public Signal: publicAmount (1011ms)
✔ Should return false for a valid proof with tampered Public Signal: extDataHash (1050ms)


5 passing (6s)

4.3.3 Conclusion

The tests have successfully passed, as demonstrated above, providing strong evidence of the circuit's compatibility across EVM chains.

5

Risk Rating Methodology

The risk rating methodology used by Nethermind Security follows the principles established by the OWASP Foundation. The severity of each finding is determined by two factors: Likelihood and Impact.

Likelihood measures how likely the finding is to be uncovered and exploited by an attacker. This factor will be one of the following values:

  1. High: the issue is trivial to exploit and has no specific conditions that need to be met;
  2. Medium: the issue is moderately complex and may have some conditions that need to be met;
  3. Low: the issue is very complex and requires very specific conditions to be met.

When defining the likelihood of a finding, other factors are also considered. These can include but are not limited to motive, opportunity, exploit accessibility, ease of discovery, and ease of exploit.

Impact is a measure of the damage that may be caused if an attacker exploits the finding. This factor will be one of the following values:

  1. High: the issue can cause significant damage, such as loss of funds or the protocol entering an unrecoverable state;
  2. Medium: the issue can cause moderate damage, such as impacts that only affect a small group of users or only a particular part of the protocol;
  3. Low: the issue can cause little to no damage, such as bugs that are easily recoverable or cause unexpected interactions that cause minor inconveniences.

When defining the impact of a finding, other factors are also considered. These can include but are not limited to data and state integrity, loss of availability, financial loss, and reputation damage. After defining the likelihood and impact of an issue, the severity is determined according to the table below.

Impact / LikelihoodLowMediumHigh
HighMediumHighCritical
MediumLowMediumHigh
LowInfo / Best PracticesLowMedium
UndeterminedUndeterminedUndeterminedUndetermined

To address issues that do not fit a High, Medium or Low severity, Nethermind Security also uses three more finding severities: Informational, Best Practices, and Undetermined.

  1. Informational findings do not pose any risk to the application, but they carry some information that the audit team intends to pass to the client formally;
  2. Best Practice findings are used when some piece of code does not conform with smart contract development best practices;
  3. Undetermined findings are used when we cannot predict the impact or likelihood of the issue.

6

Issues

6.1 Avoid identical trees in EtherPool on different chains

FileFix commitTypeStatus
EtherPool.sol—InfoAcknowledged

The EtherPool contract may be vulnerable to proof and nullifier reuse if instances deployed on different chains maintain identical Merkle trees. If the state of the trees is the same, a valid proof and nullifier used on one chain could be replanted and successfully executed on another. This risk is particularly relevant if EtherPool is deployed simultaneously across multiple EVM networks and a user submits identical data to each, resulting in synchronized tree states.

Concretely, two scenarios enable this attack:

  1. Initial state: both chains start with roots[0] = zeros(levels). A deposit proof generated against the empty root is valid on any chain with that same empty root;
  2. Identical state: if the user made the same deposits on both chains, the trees may share the same root;

Recommendation

Although this scenario is improbable, it remains a technical possibility. To mitigate this, consider depositing a small, unique amount into each pool using distinct blinding factors to ensure the Merkle trees diverge immediately. A more robust programmatic solution is to incorporate the chain ID into the extDataHash and verify it within the transact() function using block.chainid to ensure proofs are domain-bound to a specific network.

Resolution

The first tx must be a deposit, and the transact() explicitly checks msg.value == uint256(_extData.extAmount). Even if the user forge the proof, he has to deposit the same amount as in the proof, thus there won't be any loss of fund even if he forge the proof. There’s no way we will launch simultaneously on multiple chains.

6.2 The MerkleTreeWithHistory does not use domain separation between leaf and internal nodes

FileFix commitTypeStatus
MerkleTreeWithHistory.sol—InfoAcknowledged

The hashLeftRight function uses Poseidon hash with the same calling convention for both leaf-level hashing and internal node hashing:

// leaf level (line 60)
bytes32 currentLevelHash = hashLeftRight(_leaf1, _leaf2);

// internal nodes (line 73)
currentLevelHash = hashLeftRight(left, right);

Without a domain tag or prefix to distinguish the two contexts, the tree is susceptible to a second-preimage attack: an attacker who can insert an arbitrary leaf value L equal to Poseidon(A, B), where A and B are valid internal node hashes, creates an ambiguity where the same value exists simultaneously as a leaf and as an internal node. A verifier that does not enforce strict tree depth can be tricked into accepting a forged inclusion proof.

In the current EtherPool deployment this risk is mitigated by: (1) the ZK circuit enforcing a fixed tree depth of 26 levels, and (2) the circuit requiring a valid commitment preimage (amount, blinding, privkey) to spend any leaf, making it computationally infeasible to exploit. However, MerkleTreeWithHistory is a standalone, inheritable contract. Any future consumer that performs Merkle inclusion verification without those ZK constraints inherits this vulnerability.

Recommendation

Introduce domain separation so that leaf hashes and internal node hashes are computed in distinct domains.

Resolution

It has no security impact, given the contract is used by our privacy pool with fixed merkle tree height both in contract and circuit.

6.3 Unreachable code in zeros() function

FileFix commitTypeStatus
MerkleTreeWithHistory.solPR #7InfoFixed

The constructor enforces _levels < 32, meaning the maximum value for levels is 31. The function zeros() is called with i == levels in _initialize() and with i up to levels - 1 in _insert(), so the maximum index ever passed to zeros() is 31. The entry for i == 32 at line 146 is therefore unreachable dead code.

Recommendation

Remove the i == 32 entry from zeros() to keep the code consistent with the enforced invariant.

7

Documentation Evaluation

Software documentation refers to the written or visual information that describes the functionality, architecture, design, and implementation of software. It provides a comprehensive overview of the software system and helps users, developers, and stakeholders understand how the software works, how to use it, and how to maintain it. Software documentation can take different forms, such as user manuals, system manuals, technical specifications, requirements documents, design documents, and code comments. Software documentation is critical in software development, enabling effective communication between developers, testers, users, and other stakeholders. It helps to ensure that everyone involved in the development process has a shared understanding of the software system and its functionality. Moreover, software documentation can improve software maintenance by providing a clear and complete understanding of the software system, making it easier for developers to maintain, modify, and update the software over time. Smart contracts can use various types of software documentation. Some of the most common types include:

  • Technical whitepaper: A technical whitepaper is a comprehensive document describing the smart contract’s design and technical details. It includes information about the purpose of the contract, its architecture, its components, and how they interact with each other;
  • User manual: A user manual is a document that provides information about how to use the smart contract. It includes step-by-step instructions on how to perform various tasks and explains the different features and functionalities of the contract;
  • Code documentation: Code documentation is a document that provides details about the code of the smart contract. It includes information about the functions, variables, and classes used in the code, as well as explanations of how they work;
  • API documentation: API documentation is a document that provides information about the API (Application Programming Interface) of the smart contract. It includes details about the methods, parameters, and responses that can be used to interact with the contract;
  • Testing documentation: Testing documentation is a document that provides information about how the smart contract was tested. It includes details about the test cases that were used, the results of the tests, and any issues that were identified during testing;
  • Audit documentation: Audit documentation includes reports, notes, and other materials related to the security audit of the smart contract. This type of documentation is critical in ensuring that the smart contract is secure and free from vulnerabilities.

These types of documentation are essential for smart contract development and maintenance. They help ensure that the contract is properly designed, implemented, and tested, and they provide a reference for developers who need to modify or maintain the contract in the future.

8

Remarks & Test Suite

The Privacy Cash EVM protocol documentation was made available through the code comments and through extensive code walkthrough. Furthermore, the Social Panda team thoroughly addressed all questions and concerns raised by the Nethermind Security team, providing valuable insights and a comprehensive understanding of the project's technical aspects.

Running tests require modification. The Hasher's bytecode and ABI contract currently is generated through circomlibjs and added to the artifacts. To run the tests the contract must be deployed in testing scripts from the bytecode and ABI.

Test suite
88Tests passed0Failed

The complete test output is in the full report.

As auditors, we recommend that any changes or updates made to the audited codebase undergo a re-audit or security review to address potential vulnerabilities or risks introduced by the modifications. By conducting a re-audit or security review of the modified codebase, you can significantly enhance the overall security of your system and reduce the likelihood of exploitation. However, we do not possess the authority or right to impose obligations or restrictions on our clients regarding codebase updates, modifications, or subsequent audits. Accordingly, the decision to seek a re-audit or security review lies solely with you.

This report is based on the scope of materials and documentation provided by you to Nethermind in order that Nethermind could conduct the security review outlined in 1. Executive Summary and 2. Audited Files. The results set out in this report may not be complete nor inclusive of all vulnerabilities. Nethermind has provided the review and this report on an as-is, where-is, and as-available basis. You agree that your access and/or use, including but not limited to any associated services, products, protocols, platforms, content, and materials, will be at your sole risk. Blockchain technology remains under development and is subject to unknown risks and flaws. The review does not extend to the compiler layer, or any other areas beyond the programming language, or other programming aspects that could present security risks. This report does not indicate the endorsement of any particular project or team, nor guarantee its security. No third party should rely on this report in any way, including for the purpose of making any decisions to buy or sell a product, service or any other asset. To the fullest extent permitted by law, Nethermind disclaims any liability in connection with this report, its content, and any related services and products and your use thereof, including, without limitation, the implied warranties of merchantability, fitness for a particular purpose, and non-infringement. Nethermind does not warrant, endorse, guarantee, or assume responsibility for any product or service advertised or offered by a third party through the product, any open source or third-party software, code, libraries, materials, or information linked to, called by, referenced by or accessible through the report, its content, and the related services and products, any hyperlinked websites, any websites or mobile applications appearing on any advertising, and Nethermind will not be a party to or in any way be responsible for monitoring any transaction between you and any third-party providers of products or services. As with the purchase or use of a product or service through any medium or in any environment, you should use your best judgment and exercise caution where appropriate. FOR AVOIDANCE OF DOUBT, THE REPORT, ITS CONTENT, ACCESS, AND/OR USAGE THEREOF, INCLUDING ANY ASSOCIATED SERVICES OR MATERIALS, SHALL NOT BE CONSIDERED OR RELIED UPON AS ANY FORM OF FINANCIAL, INVESTMENT, TAX, LEGAL, REGULATORY, OR OTHER ADVICE.