AI Security Report
Executive Summary
StealthPay is a zero-knowledge privacy pool for ERC-20 tokens. This review examined 7 Solidity files (including 2 auto-generated UltraHonk verifiers), 4 Noir circuit files, and the TypeScript SDK across 9 source modules. No critical or high severity vulnerabilities were identified. Two medium-severity items relating to admin centralization are present by design and documented. Four low and informational findings are noted for transparency.
0
Critical
0
High
2
Medium
5
Low / Info
Review Scope
The following artifacts were analyzed in full:
| File | Type | Lines |
|---|---|---|
| contracts/PrivacyPool.sol | Solidity — core logic | 314 |
| contracts/ShieldVerifier.sol | Solidity — auto-gen UltraHonk verifier | ~900 |
| contracts/SpendVerifier.sol | Solidity — auto-gen UltraHonk verifier | ~900 |
| contracts/libraries/IncrementalMerkleTree.sol | Solidity — Merkle tree library | 86 |
| contracts/libraries/poseidon2/LibPoseidon2.sol | Solidity — Poseidon2 sponge | ~450 |
| contracts/interfaces/IPrivacyPool.sol | Solidity — interface | 108 |
| circuits/shield/src/main.nr | Noir — shield circuit | 39 |
| circuits/spend/src/main.nr | Noir — spend circuit | 141 |
| circuits/lib/src/note.nr | Noir — commitment primitives | 49 |
| circuits/lib/src/merkle.nr | Noir — Merkle proof | 36 |
| sdk/src/StealthPaySDK.ts | TypeScript — SDK entry point | 279 |
| sdk/src/ProofGenerator.ts | TypeScript — proof generation | 251 |
| sdk/src/NoteManager.ts | TypeScript — Merkle tree + notes | 329 |
| sdk/src/HintStore.ts | TypeScript — 0G Storage hints | 261 |
| sdk/src/poseidon2.ts | TypeScript — hash primitives | ~120 |
| contracts/test/PrivacyPool.test.ts | Test suite | 405 |
Contract Inventory
All contracts deployed on 0G Galileo Testnet (chainId 16602), deployed 2026-04-21.
| Contract | Address | Upgradeable |
|---|---|---|
| PrivacyPool (Proxy) | 0x87fECd1AfA436490e3230C8B0B5aD49dcC1283F1 | Yes — UUPS |
| PrivacyPool (Impl) | 0x0c7aEF68936Da0c59c085d1F685dBBBf2509D9Db | — |
| ShieldVerifier | 0x89CD2172470C1aC071117Fe2085780DAA6e9656a | No — immutable |
| SpendVerifier | 0xe1E73e47CcbDB78f70A84E8757B51807E1D42386 | No — immutable |
Module Security Analysis
PrivacyPool.sol
The core contract. Inherits from five OpenZeppelin security modules: UUPSUpgradeable, AccessControlUpgradeable, PausableUpgradeable, Initializable, and ReentrancyGuardTransient.
| Property | Mechanism | Status |
|---|---|---|
| Reentrancy protection | ReentrancyGuardTransient (EIP-1153 transient storage) | ✓ Pass |
| Double-spend prevention | mapping(bytes32 => bool) _spentNullifiers | ✓ Pass |
| Commitment replay | mapping(bytes32 => bool) _knownCommitments | ✓ Pass |
| ZK proof verification | IHonkVerifier.verify() called before any state writes | ✓ Pass |
| Token safety | SafeERC20 + balance-delta accounting (deflation-safe) | ✓ Pass |
| Zero address guard | _ZeroAddress() revert on all address inputs | ✓ Pass |
| Zero amount guard | _ZeroAmount() revert on shield(amount == 0) | ✓ Pass |
| Fee cap | MAX_FEE_BPS = 1000 (10%) — enforced in initialize + setFee | ✓ Pass |
| Merkle root freshness | Reverts if params.merkleRoot != _tree.getRoot() | ✓ Pass |
| Tree capacity guard | PP__TreeFull() revert when nextIndex >= 2²⁰ | ✓ Pass |
| Upgrade safety | _disableInitializers() in constructor; 42-slot storage gap | ✓ Pass |
| Emergency mechanism | pause() + emergencyWithdraw() restricted to admin roles | ✓ Pass |
ShieldVerifier & SpendVerifier
Both verifiers are auto-generated by Barretenberg 5.x from compiled UltraHonk circuits. They are stateless (view) and immutable — no admin keys, no storage. The verification keys are embedded as constants.
| Parameter | ShieldVerifier | SpendVerifier |
|---|---|---|
| Circuit size (N) | 4,096 gates | 8,192 gates |
| log₂(N) | 12 | 13 |
| Public inputs | 9 | 16 |
| Proof system | UltraHonk | UltraHonk |
| Curve | BN254 | BN254 |
| VK hash | 0x1aa7066... | 0x0b15de1... |
| Upgradeable | No | No |
| State | Stateless | Stateless |
IncrementalMerkleTree.sol
An append-only binary Merkle tree using Poseidon2. Stores only O(depth) = O(20) nodes (filled subtree optimization). Depth 20 supports up to 2²⁰ = 1,048,576 commitments. The zero-value chain is pre-computed at initialization and matches the Noir circuit exactly.
| Property | Status |
|---|---|
| Hash function matches Noir circuit (Poseidon2 sponge, IV = n·2⁶⁴) | ✓ Pass |
| Depth bounds check (0 < depth ≤ 32) | ✓ Pass |
| Overflow check on insertion (nextIndex < 2^depth) | ✓ Pass |
| No deletion — tree is append-only (correct for UTXO model) | ✓ Pass |
| Gas: O(depth) storage reads/writes per insert | ✓ Efficient |
LibPoseidon2.sol
A Solidity port of the Poseidon2 sponge (rate=3, state=4, t=4, rounds_f=8, rounds_p=56). Credits the Noir reference implementation. The round constants and matrix diagonal are embedded as compile-time literals. Used exclusively by IncrementalMerkleTree for the hash_2(left, right) call.
| Property | Status |
|---|---|
| Parameters match BN254 Poseidon2 spec (t=4, f=8, p=56) | ✓ Pass |
| IV domain separation: IV = message_len × 2⁶⁴ | ✓ Pass |
| Matches TypeScript SDK (@zkpassport/poseidon2) test vector | ✓ Pass |
| No external calls — pure library (no reentrancy surface) | ✓ Pass |
| Field arithmetic uses mod BN254 prime throughout | ✓ Pass |
ZK Circuit Security Analysis
Shield circuit (circuits/shield/src/main.nr)
Proves that a commitment is the Poseidon2 hash of four private inputs. One public output: the commitment. The circuit enforces two additional constraints beyond the hash equation.
| Constraint | Purpose | Status |
|---|---|---|
| commitment == poseidon2_hash4(pubkey, token, amount, salt) | Commitment integrity — no fake notes | ✓ Pass |
| amount != 0 | Prevents zero-value commitments | ✓ Pass |
| amount as u64 roundtrip == amount | Prevents field-wrapping attacks (amount < 2⁶⁴) | ✓ Pass |
Spend circuit (circuits/spend/src/main.nr)
The spend circuit simultaneously proves five independent properties in zero knowledge. All amounts are u64 range-checked (capped at 2⁶⁴ − 1 ≈ 1.84 × 10¹⁹ base units) to prevent field arithmetic overflow.
| Constraint | Attack prevented | Status |
|---|---|---|
| spending_pubkey == poseidon2_hash2(privkey, 1) | Key impersonation | ✓ Pass |
| commitment ∈ Merkle tree (sibling-path proof) | Phantom note spending | ✓ Pass |
| nullifier == poseidon2_hash2(privkey, commitment) | Nullifier forgery | ✓ Pass |
| output commitment == poseidon2_hash4(pubkey, token, amt, s) | Output inflation | ✓ Pass |
| sum(inputs) == sum(outputs) + public_amount | Value creation (inflation) | ✓ Pass |
| u64 range check on all 5 amounts | Field wrap-around exploit | ✓ Pass |
| public_amount == 0 implies recipient == 0 | Accidental public release | ✓ Pass |
| disabled input: nullifier must be 0 | Phantom nullifier injection | ✓ Pass |
| disabled output: commitment must be 0 | Phantom commitment insert | ✓ Pass |
Access Control Review
The contract uses OpenZeppelin's role-based access control. Three roles exist beyond the default admin. All roles are initially granted to the deployer.
| Role | keccak256 hash | Permissions |
|---|---|---|
| DEFAULT_ADMIN_ROLE | 0x0000...0000 | Grant/revoke roles · setProtocolFee · setFeeRecipient · emergencyWithdraw |
| PAUSER_ROLE | keccak256("PAUSER_ROLE") | pause() · unpause() |
| UPGRADER_ROLE | keccak256("UPGRADER_ROLE") | _authorizeUpgrade() — controls implementation upgrades |
| OPERATOR_ROLE | keccak256("OPERATOR_ROLE") | whitelistToken() · delistToken() |
0xD91D61bd2841839eA8c37581F033C9a91Be6a5A6). For mainnet, the team should consider transferring UPGRADER_ROLE and DEFAULT_ADMIN_ROLE to a multisig with a timelock.Test Coverage Summary
The test suite (contracts/test/PrivacyPool.test.ts) contains 18 test cases across 5 describe blocks, using Hardhat + Mocha + Chai + OpenZeppelin upgrade test helpers.
| Suite | Tests | Coverage highlights |
|---|---|---|
| initialize | 4 | Zero admin/verifier revert, fee cap revert, fee/recipient state |
| shield() | 7 | Valid shield, tree growth, fee accounting, invalid proof revert, non-whitelisted token, zero amount, duplicate commitment, paused revert |
| spend() — unshield | 4 | Token release, nullifier marking, double-spend revert, stale root revert, invalid proof revert |
| spend() — private transfer | 1 | Commitment insertion without token movement, tree size delta |
| admin | 5 | Fee update, fee cap enforcement, fee recipient update, non-admin revert, emergency withdraw, non-admin emergency withdraw revert |
| upgrade | 1 | UUPS upgradeability by UPGRADER_ROLE |
circuits/shield/src/shield_test.nr and circuits/spend/src/spend_test.nr. SDK unit tests cover Poseidon2 vectors, NoteManager tree consistency, and proof round-trips via sdk/test/ (5 test files).Findings
Admin holds all roles without a timelock
Component: PrivacyPool.sol — access control
Description: UPGRADER_ROLE, PAUSER_ROLE, OPERATOR_ROLE, and DEFAULT_ADMIN_ROLE are all held by a single EOA. An upgrade to a malicious implementation, or a pause followed by an emergencyWithdraw, can be executed in a single transaction with no delay.
Recommendation: Transfer UPGRADER_ROLE and DEFAULT_ADMIN_ROLE to a multisig (e.g. Gnosis Safe 3-of-5). Add a 48-hour timelock to the upgrade path for mainnet.
emergencyWithdraw can drain the pool
Component: PrivacyPool.sol — emergencyWithdraw()
Description: The DEFAULT_ADMIN role can call emergencyWithdraw(token, to, amount) with no restriction on the amount or the destination address, effectively allowing a full drain of pool reserves. This is an intentional admin escape hatch but represents a trust assumption users must accept.
Recommendation: Document this clearly for users. For mainnet, gate this function behind the multisig + timelock mentioned in SP-01. Consider adding a cap (e.g. max 10% of balance per call per day).
Prover.toml written to disk with spending privkey
Component: sdk/src/ProofGenerator.ts
Description: During proof generation, the SDK writes the full TOML witness file (which includes spending_privkey) to the circuits directory on disk before invoking nargo execute. On multi-user systems or systems with process inspection, this is a key-exposure risk.
Recommendation: Write the Prover.toml to a tmpdirwith 0600 permissions and delete it immediately after witness generation. Or use nargo's stdin input if supported.
recordHint() is permissionless — potential event spam
Component: PrivacyPool.sol — recordHint()
Description: Any address can call recordHint(receiverPubkeyHash, storageRoot) and emit a NoteHint event. While hints are encrypted and a wrong key produces null on decryption, an adversary could spam millions of fake hint events, causing DoS for the SDK's scanHints() function.
Recommendation: By design and acceptable for privacy. For production, the SDK should impose a rate limit or max-hints-per-scan ceiling and short-circuit gracefully if scan cost exceeds a threshold.
Proof becomes stale if Merkle root advances between generation and submission
Component: PrivacyPool.sol — spend()
Description: The spend circuit proves against the Merkle root at proof-generation time. The contract enforces the proof root equals the current on-chain root. If another user shields a token between proof generation and transaction inclusion, the proof is rejected (PP__InvalidMerkleRoot). This is not a vulnerability but creates UX friction under high pool activity.
Recommendation: Acceptable by design. Users should generate proofs immediately before submission. The SDK handles this correctly. Document the behavior for integrators.
SDK & Off-chain Security
| Component | Security property | Status |
|---|---|---|
| StealthPaySDK.ts | spendingPrivkey never serialized or transmitted over the wire | ✓ Pass |
| StealthPaySDK.ts | Salt generated via ethers.randomBytes(32) mod BN254 prime — cryptographically secure | ✓ Pass |
| NoteManager.ts | Local Merkle tree uses identical Poseidon2 IV as on-chain library | ✓ Pass |
| NoteManager.ts | Tree sync replays events in block/log-index order — no ordering bugs | ✓ Pass |
| HintStore.ts | ECIES (secp256k1 + AES-256-GCM + HKDF via keccak256) for hint encryption | ✓ Pass |
| HintStore.ts | Encryption key domain-separated from ZK key (domain constant = 3) | ✓ Pass |
| ProofGenerator.ts | Proof temp dir is cleaned up with rmSync in finally block | ✓ Pass |
| poseidon2.ts | Passes official Barretenberg test vector for permute([0,1,2,3]) | ✓ Pass |
Summary of All Findings
| ID | Severity | Component | Title | Status |
|---|---|---|---|---|
| SP-01 | MEDIUM | PrivacyPool.sol | No timelock on admin roles / upgrade path | By Design · Rec: add multisig + timelock |
| SP-02 | MEDIUM | PrivacyPool.sol | emergencyWithdraw can drain pool | By Design · Rec: multisig gate |
| SP-03 | LOW | ProofGenerator.ts | Prover.toml with privkey written to disk | Open · Rec: tmpdir + 0600 permissions |
| SP-04 | INFO | PrivacyPool.sol | recordHint() permissionless — event spam vector | Acceptable · SDK should bound scan cost |
| SP-05 | INFO | PrivacyPool.sol | Stale Merkle root causes spend rejection under concurrent activity | By Design · Document for integrators |
Pre-Mainnet Recommendations
- 1Transfer DEFAULT_ADMIN_ROLE and UPGRADER_ROLE to a multisig (minimum 3-of-5). Add a 48-hour timelock to all upgrade transactions.
- 2Consider capping emergencyWithdraw per-call or requiring a 24-hour delay via the timelock.
- 3Write Prover.toml to an OS temp directory with 0600 permissions and delete it immediately after witness generation.
- 4Commission a formal third-party audit of the Solidity contracts and Noir circuits from a ZK-specialized firm before mainnet launch.
- 5Conduct a trusted setup ceremony or document the use of the UltraHonk universal SRS (no toxic waste per Barretenberg design).
- 6Add SDK documentation warning that spending proofs must be submitted promptly as they prove against the current Merkle root.
- 7Set up an on-chain monitoring service (e.g. Tenderly alerts) for NullifierAlreadySpent, InvalidZKProof, and EmergencyWithdrawal events.
Conclusion
StealthPay v2 demonstrates a well-structured privacy protocol with correct application of zero-knowledge proof techniques. The critical on-chain security properties — double-spend prevention, commitment integrity, value conservation, and Merkle membership — are all enforced at the circuit level and independently verified by the on-chain UltraHonk verifiers. No critical or high severity vulnerabilities were found.
The two medium findings (admin centralization and emergency withdraw) are intentional design choices that are standard in early-stage DeFi protocols and can be mitigated with a multisig and timelock before mainnet. The low and informational findings are improvement opportunities that do not compromise protocol correctness.