Privacy Cash Audit Case Study

Executive Summary

Privacy Cash engaged Hashcloak for the audit of Privacy Cash: a privacy protocol on Solana and Base that enables private transfers and swaps without linking your wallet addresses or transaction history. The link between deposits and withdrawals is broken down using an append only merkle tree and zero-knowledge proofs. The protcol was initially implemented for solana ecosystem but later also implemented for the Base Network(EVM). The protocol draws inspiration from Tornado Cash implementation. We also audited the EVM version of privacy cash supporting private transfer on Base network however this case-study only focuses for the Solana Network.

Protocol Overview

Privacy Cash is a privacy preserving transaction protocol that allows users to deposit or withdraw tokens without linking depositors to withdrawers on-chain. It uses Groth16 zero-knowledge proofs over BN254 and a Poseidon-hashed append only Merkle tree to achieve this.

The protocol is a utxo based scheme where each transaction consumes 2 existing notes(utxos) and produce 2 new notes(utxos) enforcing the balance constraints.


privacy cash


Pic: Privacy Cash General flow overview

Main components

Layer

Tech

Role

On-chain program

Anchor(Rust), Solidity

Proof verification, Merkle state management, fund custody

Zk-circuits

circom

Enforce transaction validity off-chain by constraining input output validity, balance invariant, unique nullifier and external data hash binding

Client SDK

typescript

UTXO management, proof generation, tx construction

Protocol Flow

  • System Setup

The authority initialises one Merkle tree per supported asset. A single tree PDA derived from the seed [b"merkle_tree"] for SOL, and per-mint trees derived from seed [b"merkle_tree", mint] for each allowlisted SPL token. Each tree has height 26, a 100-slot root history buffer, and is seeded with Poseidon zero-byte values. A GlobalConfig PDA holds the fee parameters. The pool custodies funds in a TreeTokenAccount PDA.

All value inside the protocol is held as notes. A note is a tuple $(amount, pk_{utxo}, ρ, mint)$ where $pk_{utxo} = Poseidon(sk_{utxo})$ is the user's in-protocol public key and $ρ$ is a random blinding factor. Its on-chain representation is a commitment:

$$Comm = Poseidon(amount, pk_{utxo}, \rho, mintAddress)$$

To spend a note at leaf index i, the owner derives its nullifier:

$$nullifier = Poseidon(Comm, merklePath, sign)$$

where $\mathbf{sign}=Poseidon(sk_{utxo}, Comm, merklePath)$

  • Transaction

Every protocol interaction is a join-split: 2 input notes consumed, 2 output notes created.


deposit-withdrawal


Pic: Full end-to-end protocol flow

  • Deposit

The user fetches two unspent utxo from his/her existing unspent utxos(in case of fresh deposit, dummy inputs utxo are created). The SDK fetches the current Merkle root, next insertion index, and inclusion proofs for the selected input commitments. The SDK constructs two output notes $(a, pk, ρ₀, mint)$ and $(0, pk, ρ₁, mint)$, computes their commitments and encrypts both using encryption key. The SDK computes input nullifiers and output commitments using the UTXO secrets, public keys, blinding factors, and Merkle indices. The output UTXOs are then encrypted using the user’s encryption key, producing encrypted outputs that can later be scanned and decrypted by the owner.

A Groth16 proof is generated over the transaction witness, proving:

  • knowledge of the input UTXOs,

  • valid Merkle inclusion of spent commitments,

  • correct nullifier derivation,

  • correct creation of output commitments, and

  • balance conservation (sum(inputs) + publicAmount = sum(outputs)).

The external parameters are bound via:

$$\mathsf{extHash} = \mathsf{SHA\text{-}256}(\mathsf{recipient},\ a,\ \mathsf{enc}_0,\ \mathsf{enc}_1,
\mathsf{fee},\ \mathsf{feeRecipient},\ \mathsf{mintAddress})$$

The proof, public inputs, encrypted outputs, and auxiliary transaction data are serialized into the deposit instruction and transaction is constructed.

On-chain:
The program validates the following:

  • Existance of nullifier PDAs to prevent double spending

  • Existance of root in root history

  • extDataHash against computed hash

  • Verify groth16 proof

After validation, commitments are appended to the tree and events emitted. The amount is added in the pool which can be later spend by the user.

  • Withdrawal

The user fetches two unspent utxo from his/her existing unspent utxos covering amount a + fee. For each selected note at leaf i, a Merkle path is built from the current tree state. Nullifiers nf₀, nf₁ are derived. Change is encoded in the first output note; a zero-value dummy fills the second. publicAmount = -(a + fee) and extHash is computed with recipient R.

On-chain:
Same as deposit. Additonally calculates fees and pool.lamports ≥ a + fee + rent_exempt_minimum.

The token amount a is then transfered to the receipent and fee to feeRecipient. The Change commitments are appended to the tree and events emitted.

Audit Scope and Methodology

Scope:

  • Privacy-cash

    • anchor/programs/*

    • circuits/*

  • Privacy-cash-sdk

    • src/*

The engagement covered four primary threat surfaces: (1) ZK circuit soundness and completeness, (2) on-chain proof and state validation, (3) fund custody and token transfer logic, and (4) cryptographic primitive usage across three layers of the protocol: ZK circuits, On-chain program and Client SDK.

  • ZK circuits (circuits/) — constraint correctness, soundness, completeness, and public input binding.

  • On-chain program (anchor/programs/zkcash/src/) — account validation, arithmetic safety, fund custody, and proof verification logic.

  • Client SDK (privacy-cash-sdk/src/*) — UTXO construction, hash computation, proof serialisation, and cross-layer consistency with the on-chain program.

The Audit methodology included precise scope boundary, modelling the adversarial threat surface by identifying protocol's boundries and potential attacker roles such as malacious users or any external attacker. The technical phase also focused on understanding the full data flow across the three layers before examining any individual function to acheive the complete picture of the protocol.
We also used different static analyzer tools such as Circomspect, X-ray and Radar for finding common security vulnerabilities in solana programs and circom circuits.

diagram-export-11-06-2026-20_23_39 (2)


Pic: A cyclomatic complexity (CC) heatmap showing function complexity in the codebase

Challenges


atlbri

credit: https://imgflip.com/memetemplates

One of the main challenge while auditing Privacy Cash was that the protocol's security was distributed across three separate execution environments. The Circom circuits enforce the cryptographic validity of a transaction, the Solana program enforces state transitions and fund custody, and the SDK constructs witnesses, generates proofs, and derives all cryptographic material. A vulnerability could arise even when each individual component appears correct in isolation. The primary challenge was therefore verifying cross-layer consistency and ensuring that every value committed inside the proof matched the values reconstructed by the SDK and later validated by the on-chain program.

For instance, lets talk about the lifecycle of nullifiers in the protocol, a very important component in our case.

On the SDK side, the nullifiers are generated by hashing the commitment value, the index and signature. The same nullifier is being fed as an input for proof generation of the circom circuits. The proof and public signals(which contains the nullifiers as well) are parsed into bytes and then created into a Proof object to submit to the program. Moreover, the nullifier PDAs are extracted for the given nullifiers present in the Proof object which are then added in the deposit/withdrawal instruction along with the seralized Proof object. The on-chain program creates a nullifiers PDA using the proof's input nullifiers to mark the input as spent using init to ensure that the transaction automatically fail with a system program error if the nullifier has already been used (i.e., if the account already exists) thus preventing double spending.

Now in this whole lifecycle of nullifiers, if at any point the nullifier used in the SDK is different from the one being fed in circom circuit or in the Solana program, serious vulnerabilities can arise. Such inconsistencies can potentially arise for number of reasons such as during proof (de)seralization, byte-ordering mismatches, differing numeric representations, or encoding discrepancies across the various components of the system. Since these issues occur at the boundaries between layers, they are often difficult to detect when reviewing any single component in isolation. The similar challenge was true for merkle Trees as well which also had similar off-chain, on-chain and circuit components.

A second challenge arose from Privacy Cash’s architectural roots in Tornado Cash Nova. While Privacy Cash inherits many of Tornado Cash’s core concepts such as the join-split UTXO model, Merkle-tree-based commitments, nullifiers, and Groth16 proofs, the protocol is implemented on Solana, whose execution model differs fundamentally from the EVM.

On Ethereum, contracts maintain their own storage and can directly manage state and assets. In contrast, Solana has no contract-local storage; all state resides in separate accounts that must be explicitly provided to each instruction. As a result, components that exist as simple storage variables or mappings in Tornado Cash become independent PDA accounts in Privacy Cash. For example, Tornado Cash stores its Merkle tree and nullifier set directly in contract storage, whereas Privacy Cash maintains the Merkle tree in a dedicated PDA account and represents each nullifier as a separate PDA. Similarly, while Ethereum contracts can directly transfer assets they custody, Solana PDAs cannot sign transactions and require additional program logic to authorize fund movements. These differences meant that the audit could not rely solely on the security properties of the original Tornado Cash design. Every translation point such as PDA derivation and account ownership checks, replay protection, state initialization, and custody mechanisms required independent analysis to verify that the security guarantees of the system.

Approach During the audit

The first and foremost thing before even the start of the audit is to understand the protocol as a whole and identify the core security invariants that must hold across all layers of the system before examining the implementation details. The quick way to do that is to look at the end-to-end testing which gives us the entire flow. Therefore, we first began understaing the protocol by looking at the end to end test followed by reconstructing the complete lifecycle of a note within the protocol by looking into the codes/files usually in a top-down approach i.e., first looking at the entrypoint in the codebase followed by inner files/functions. We traced a note from its creation in the SDK, through commitment generation, witness construction, proof generation, on-chain verification, insertion into the Merkle tree, and finally its consumption through nullifier creation. This helps us to understand and outline the potential security checklist applicable for the protocol.

As Privacy Cash mantains privacy using Zero-knowledge proofs hence, the other thing which we looked out is any security vulnerabilities which violates the fundamental security properties(completeness, soundness, zero-knowledge) of the ZKP system and prepare checklist around that. The most common faliure of security gurantee is the soudness which arise due to under-constrained circuits. Some of under-constrained issues commonly airse are due to non-deterministic circuits, missing/mismatch bit-length check, assigned but not constrained inputs, unconstrained public inputs, unconstrained division and overflow/underflow. For circom we used Circomspect, a static analyzer for finding common security vulnerabilities in circom circuits.


diagram-export-11-06-2026-20_23_39 (1)


Pic: ZK Circuit(Circom) Internals

Once we have an in-depth knowledge of the overall protocol and potential security checklist, we start the review. we look for potential edge cases and bottlenecks in the functions because that is what generally gets overlooked.
Apart from circuit specific issues focus on issues at the protocol level such as nullifier check for double spending, Merkle root validation, proof verification, fees logic, external data hash calculation, Amount invariance, distinct nullifiers. Every single checklist has multiple things to look into. For instance, as simple as checking a root is a known root from a 100-slot root history buffer we need to check that a zero-root is not considered to be a valid root, the root index which is used to store the root history is correctly updated, the loop correctly iterates over intire root history, the equality comparison does not reject any valid roots.

Additionally, we also looked for Solana specific issues such as Authorization checks, PDA derivations(merkle tree, nullifier etc), transaction replay, account ownerships, rent exemption, account re-initialization etc. There are multiple static analyzers such as X-ray and Radar for finding common security vulnerabilities in Solana programs which we used.

On the SDK side we first focused on the correct usage of cryptographic primitives used in the protocol such as generation of UTXOs keys and encryption keys, the encryption protocol, use of strong randomness for blinding utxos, merkle proof construction, the proof generation and not to forget the input validation and transaction construction.

Because of distinct technical layers which includes a Circom ZK circuit, a Rust/Anchor on-chain program, and a TypeScript client SDK, each written in a different language with different type systems and numeric representations we also need to ensure that logical values in all three layers must produce identical results. Any subtle difference in byte ordering, field reductions, integer representations, or serialization formats across layers is a potential exploit vector, and such differences are invisible within any single layer in isolation.

Outcomes and Impact

The audit identified several issues spanning the on-chain program, ZK circuits, and SDK. While some findings were traditional security vulnerabilities, the most interesting observations emerged at the boundaries between different layers of the system, where assumptions made by one component were not always enforced by another.

Let's discuss some of the key findings.

Issue 1: Double-Spends Through PDA Manipulation1

Type: Critical

File: anchor/programs/zkcash/src/lib.rs

Description: One of the most impactful findings involved the protocol’s double-spending bug. Privacy Cash represents spent notes using nullifier PDA accounts. During the review of the note-spending lifecycle, we traced how nullifiers were generated in the SDK, committed inside the proof, and finally materialized as PDA accounts on-chain. While analyzing the relationship between these components, we observed that the protocol derived the two nullifier accounts using different seed prefixes (nullifier0 and nullifier1). Now if we look at each layers of the protocols where nullifiers are used, in SDK the nullifiers are generated in a deterministic way by hashing commitment value, index and signature. Since, commitment uses a random binding factor, the nullifiers will be distinct with high probability. Moreover, in the circom circuit there is constraint check whether same nullifiers among all inputs exits or not to ensure distinct nullifiers. Additonally, in solana program distinct nullifiers result in distinct nullifier PDA creation as well as for a given nullifier, same nullifier PDA will be generated. That ensures the deterministic way to generating nullifier PDA from the nullifiers. However, the issue lies due to the seed pre-fix being used during nullifier PDA generation. So, in order to double spend, an attacker should be able to generate different nullifiers PDA for the same input nullifiers. This is where the attack lies. When a user deposits or withdraws funds, the program creates two nullifier accounts using the init constraint. These accounts are created based on the nullifiers provided in the proof, with seeds [b"nullifier0", proof.input_nullifiers[0].as_ref()] and [b"nullifier1", proof.input_nullifiers[1].as_ref()] for input nullifier 0 and input nullifier 1, respectively. This action marks the inputs as spent. So, if a nullifier account already exists, the transaction automatically fails with a system program error, thus preventing double spend. However, since the seed prefixes for the two nullifiers differ (b"nullifier0" and b"nullifier1"), a user can bypass this check and double-spend by simply swapping the input nullifiers in the provided proof. Swapping inputs nullifiers will result in different nullifiers PDA generation due to the seed prefix as the cross PDA generated from [b"nullifier0", proof.input_nullifiers[1].as_ref()] and [b"nullifier1", proof.input_nullifiers[0].as_ref()] will be different from the original PDA so the transaction will not be able to detect that the notes are already spent, thus allowing the double-spend to occur.

Fix: To avoid any breaking changes we wanted to keep the current nullifiers account as it is but cross nullifier account initialization check is added with seeds
[b"nullifier0",proof.input_nullifiers[1].as_ref()] and
[b"nullifier1",proof.input_nullifiers[0].as_ref()] was needed. The proposed solution discussed was to define two cross nullifier accounts, namely nullifier2 and nullifier3 as SystemAccount. This would prevent double spend by rejecting a second transaction with swapped nullifiers, since the nullifier2 and nullifier3 accounts will be program owned and not system owned, thus preventing double spending.

Issue 2: Underconstrained check in the circuit for creating proof for transaction

File: circuits/transaction.circom

Description: One of the common issues arise in ZKP system is due to under-constained which violates the soundness property of ZKP. In transaction.circom, each input and output note carries its own independent mint address signal:

signal input inMintAddress[nIns];   
signal input outMintAddress[nOuts]

signal input inMintAddress[nIns];   
signal input outMintAddress[nOuts]

signal input inMintAddress[nIns];   
signal input outMintAddress[nOuts]

and then each commitment is hashed with it's own mint address but the thing is missing is the constraint that all input mint address and output mint address are all equal as well as equal with each other. Since, all notes in a transaction must belong to the same asset we must also ensure that in the circuit which was found to be missing. Other thing which was noted was the balance invarint:

This does not include multiple asset as all. This actually can potentially create a cross note creation scenario where an input mint address is given to be an mintAddress1 and out mint address is given to be something different mintAddress2 and the out commitment can be inserted into the mintAddress2 commitment merkle tree. Since, the commitment is supposed to be the source of truth for what asset a note represents, we must ensure the same constraint in the circuit and proof verification logic.

Fix: The fix for this was fairly simple. the input should be replaced with a single MintAddress which is then used in all the input and output commitments which automatically ensures same mint address for all utxos.

Issue 3: Generation of Encryption keys and UTXOs Keypair from fixed signature

File: src/utils/encryption.ts

Description: In cryptography, key generation is a very importatnt step in all cryptographic protocol and we must ensure that a key is generated in cryptographic random fashion. Some of the ways are either using a cryptographic PRNG or sometimes we also use a key-derivation function which generates keys in a pseudo-random fashion. So we were expecting something similar to be done in Privacy cash as well but here the keys are generated in quite an unconventional way.

The function deriveEncryptionKeyFromSignature is used to derive the encryption keys and the UTXO’s keypair from signature which is generated by wallet signing a constant message:

// Sign a constant message with the keypair
const message = Buffer.from('Privacy Money account sign in');
const signature = nacl.sign.detached(message, keypair.secretKey);
return this.deriveEncryptionKeyFromSignature(signature)
// Sign a constant message with the keypair
const message = Buffer.from('Privacy Money account sign in');
const signature = nacl.sign.detached(message, keypair.secretKey);
return this.deriveEncryptionKeyFromSignature(signature)
// Sign a constant message with the keypair
const message = Buffer.from('Privacy Money account sign in');
const signature = nacl.sign.detached(message, keypair.secretKey);
return this.deriveEncryptionKeyFromSignature(signature)

and then hashing the first 31 bytes of the signature to obtain the encryption key. While this approach may function correctly in the current context, it introduces some risk. If any third party site tricks a user into signing the same message with their wallet, that exposes all the keys: its encryption keys, utxos public key and private key. Exposed keys can drains all the privacy pool funds for that user. Moreover, instead of using full signature bytes only the first 31 bytes are taken which result in reduced security. Although phishing attacks cannot be fully mitigated, the current design makes such attacks easier to execute against PrivacyCash users. The recommendation was given to use a key-derivation function (hkdf) to generate keys from wallet private key but the team decided to go with the same approch as it was done the same way in tornado cash.

These are some of the issues being discussed here. One can see the full report here.

Conclusion

The central lesson of this engagement was that cross-layer consistency is the hardest property to verify and the easiest to overlook.

The engagement also illustrates the significance of design inheritance and its limits. Privacy Cash is architecturally descended from Tornado Cash Nova: the join-split UTXO model, the Groth16 circuit structure, the extDataHash binding mechanism, and the note commitment scheme are all directly inherited. This inheritance provides a well-understood security foundation. However, the translation from Ethereum to Solana is not a mechanical port. It requires rethinking every mechanism that relied on EVM-native primitives. For instance, the nullifier mapping becomes a PDA existence check. Each translation point is a potential divergence from the original security model, and each required independent verification rather than inherited trust from the Ethereum design.

References

  1. Privacy Cash: https://github.com/Privacy-Cash/privacy-cash

  2. Privacy Cash SDK: https://github.com/Privacy-Cash/privacy-cash-sdk

  3. Tornado Nova: https://github.com/tornadocash/tornado-nova

  4. Circomspect: https://github.com/trailofbits/circomspect

  5. X-ray: https://github.com/sec3-product/x-ray

  6. Radar: https://github.com/Auditware/radar

  7. Solana docs: https://solana.com/docs

Footnotes

  1. This vulnerability was originally identified and reported by David(@dr497_)