Security

AI Security Report

ProtocolStealthPay v2 (ZK proof-based)
Review dateApril 30, 2026
ReviewerAI-assisted static + circuit analysis
StatusPre-Mainnet Draft
ScopeSmart contracts, ZK circuits, TypeScript SDK
Chain0G Galileo Testnet (chainId 16602) · Mainnet-pending
This report is an AI-assisted analysis of the StealthPay codebase conducted prior to mainnet deployment. It is not a substitute for a formal third-party audit. Users and integrators should treat it as a complement to, not a replacement for, professional security review.

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:

FileTypeLines
contracts/PrivacyPool.solSolidity — core logic314
contracts/ShieldVerifier.solSolidity — auto-gen UltraHonk verifier~900
contracts/SpendVerifier.solSolidity — auto-gen UltraHonk verifier~900
contracts/libraries/IncrementalMerkleTree.solSolidity — Merkle tree library86
contracts/libraries/poseidon2/LibPoseidon2.solSolidity — Poseidon2 sponge~450
contracts/interfaces/IPrivacyPool.solSolidity — interface108
circuits/shield/src/main.nrNoir — shield circuit39
circuits/spend/src/main.nrNoir — spend circuit141
circuits/lib/src/note.nrNoir — commitment primitives49
circuits/lib/src/merkle.nrNoir — Merkle proof36
sdk/src/StealthPaySDK.tsTypeScript — SDK entry point279
sdk/src/ProofGenerator.tsTypeScript — proof generation251
sdk/src/NoteManager.tsTypeScript — Merkle tree + notes329
sdk/src/HintStore.tsTypeScript — 0G Storage hints261
sdk/src/poseidon2.tsTypeScript — hash primitives~120
contracts/test/PrivacyPool.test.tsTest suite405

Contract Inventory

All contracts deployed on 0G Galileo Testnet (chainId 16602), deployed 2026-04-21.

ContractAddressUpgradeable
PrivacyPool (Proxy)0x87fECd1AfA436490e3230C8B0B5aD49dcC1283F1Yes — UUPS
PrivacyPool (Impl)0x0c7aEF68936Da0c59c085d1F685dBBBf2509D9Db
ShieldVerifier0x89CD2172470C1aC071117Fe2085780DAA6e9656aNo — immutable
SpendVerifier0xe1E73e47CcbDB78f70A84E8757B51807E1D42386No — immutable

Module Security Analysis

PrivacyPool.sol

The core contract. Inherits from five OpenZeppelin security modules: UUPSUpgradeable, AccessControlUpgradeable, PausableUpgradeable, Initializable, and ReentrancyGuardTransient.

PropertyMechanismStatus
Reentrancy protectionReentrancyGuardTransient (EIP-1153 transient storage)✓ Pass
Double-spend preventionmapping(bytes32 => bool) _spentNullifiers✓ Pass
Commitment replaymapping(bytes32 => bool) _knownCommitments✓ Pass
ZK proof verificationIHonkVerifier.verify() called before any state writes✓ Pass
Token safetySafeERC20 + 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 capMAX_FEE_BPS = 1000 (10%) — enforced in initialize + setFee✓ Pass
Merkle root freshnessReverts if params.merkleRoot != _tree.getRoot()✓ Pass
Tree capacity guardPP__TreeFull() revert when nextIndex >= 2²⁰✓ Pass
Upgrade safety_disableInitializers() in constructor; 42-slot storage gap✓ Pass
Emergency mechanismpause() + 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.

ParameterShieldVerifierSpendVerifier
Circuit size (N)4,096 gates8,192 gates
log₂(N)1213
Public inputs916
Proof systemUltraHonkUltraHonk
CurveBN254BN254
VK hash0x1aa7066...0x0b15de1...
UpgradeableNoNo
StateStatelessStateless
The verifier contracts are derived directly from the circuit artifacts. Any tampering with the verification key would cause all valid proofs to fail, making forgery effectively impossible without breaking the underlying proof system.

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.

PropertyStatus
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.

PropertyStatus
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.

ConstraintPurposeStatus
commitment == poseidon2_hash4(pubkey, token, amount, salt)Commitment integrity — no fake notes✓ Pass
amount != 0Prevents zero-value commitments✓ Pass
amount as u64 roundtrip == amountPrevents 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.

ConstraintAttack preventedStatus
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_amountValue creation (inflation)✓ Pass
u64 range check on all 5 amountsField wrap-around exploit✓ Pass
public_amount == 0 implies recipient == 0Accidental public release✓ Pass
disabled input: nullifier must be 0Phantom nullifier injection✓ Pass
disabled output: commitment must be 0Phantom 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.

Rolekeccak256 hashPermissions
DEFAULT_ADMIN_ROLE0x0000...0000Grant/revoke roles · setProtocolFee · setFeeRecipient · emergencyWithdraw
PAUSER_ROLEkeccak256("PAUSER_ROLE")pause() · unpause()
UPGRADER_ROLEkeccak256("UPGRADER_ROLE")_authorizeUpgrade() — controls implementation upgrades
OPERATOR_ROLEkeccak256("OPERATOR_ROLE")whitelistToken() · delistToken()
All four roles are currently held by a single deployer EOA (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.

SuiteTestsCoverage highlights
initialize4Zero admin/verifier revert, fee cap revert, fee/recipient state
shield()7Valid shield, tree growth, fee accounting, invalid proof revert, non-whitelisted token, zero amount, duplicate commitment, paused revert
spend() — unshield4Token release, nullifier marking, double-spend revert, stale root revert, invalid proof revert
spend() — private transfer1Commitment insertion without token movement, tree size delta
admin5Fee update, fee cap enforcement, fee recipient update, non-admin revert, emergency withdraw, non-admin emergency withdraw revert
upgrade1UUPS upgradeability by UPGRADER_ROLE
Circuit-level tests exist in 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

SP-01

Admin holds all roles without a timelock

MEDIUM

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.

SP-02

emergencyWithdraw can drain the pool

MEDIUM

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).

SP-03

Prover.toml written to disk with spending privkey

LOW

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.

SP-04

recordHint() is permissionless — potential event spam

INFO

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.

SP-05

Proof becomes stale if Merkle root advances between generation and submission

INFO

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

ComponentSecurity propertyStatus
StealthPaySDK.tsspendingPrivkey never serialized or transmitted over the wire✓ Pass
StealthPaySDK.tsSalt generated via ethers.randomBytes(32) mod BN254 prime — cryptographically secure✓ Pass
NoteManager.tsLocal Merkle tree uses identical Poseidon2 IV as on-chain library✓ Pass
NoteManager.tsTree sync replays events in block/log-index order — no ordering bugs✓ Pass
HintStore.tsECIES (secp256k1 + AES-256-GCM + HKDF via keccak256) for hint encryption✓ Pass
HintStore.tsEncryption key domain-separated from ZK key (domain constant = 3)✓ Pass
ProofGenerator.tsProof temp dir is cleaned up with rmSync in finally block✓ Pass
poseidon2.tsPasses official Barretenberg test vector for permute([0,1,2,3])✓ Pass

Summary of All Findings

IDSeverityComponentTitleStatus
SP-01MEDIUMPrivacyPool.solNo timelock on admin roles / upgrade pathBy Design · Rec: add multisig + timelock
SP-02MEDIUMPrivacyPool.solemergencyWithdraw can drain poolBy Design · Rec: multisig gate
SP-03LOWProofGenerator.tsProver.toml with privkey written to diskOpen · Rec: tmpdir + 0600 permissions
SP-04INFOPrivacyPool.solrecordHint() permissionless — event spam vectorAcceptable · SDK should bound scan cost
SP-05INFOPrivacyPool.solStale Merkle root causes spend rejection under concurrent activityBy Design · Document for integrators

Pre-Mainnet Recommendations

  1. 1Transfer DEFAULT_ADMIN_ROLE and UPGRADER_ROLE to a multisig (minimum 3-of-5). Add a 48-hour timelock to all upgrade transactions.
  2. 2Consider capping emergencyWithdraw per-call or requiring a 24-hour delay via the timelock.
  3. 3Write Prover.toml to an OS temp directory with 0600 permissions and delete it immediately after witness generation.
  4. 4Commission a formal third-party audit of the Solidity contracts and Noir circuits from a ZK-specialized firm before mainnet launch.
  5. 5Conduct a trusted setup ceremony or document the use of the UltraHonk universal SRS (no toxic waste per Barretenberg design).
  6. 6Add SDK documentation warning that spending proofs must be submitted promptly as they prove against the current Merkle root.
  7. 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.

This report covers 7 smart contracts, 4 circuit files, and 9 SDK modules analyzed as of April 30, 2026. A formal third-party audit is strongly recommended before mainnet launch to validate these findings independently.
AI-assisted review · StealthPay v2 · April 2026Contracts: 4 reviewedCircuits: 4 reviewedTest cases: 18Critical: 0 · High: 0 · Medium: 2 · Low/Info: 5