Veilon Info
A zk-native cross-chain wallet and protocol for private, untraceable transfers, swaps, and bridging powered by stealth addresses & encrypted transaction layers.
TrustNet Score
The TrustNet Score evaluates crypto projects based on audit results, security, KYC verification, and social media presence. This score offers a quick, transparent view of a project's credibility, helping users make informed decisions in the Web3 space.
Real-Time Threat Detection
Real-time threat detection, powered by Cyvers.io,
is currently not
activated
for this project.
This advanced feature provides continuous monitoring and instant alerts to safeguard your assets from potential security threats. Real-time detection enhances your project's security by proactively identifying and mitigating risks.
For more information, click here.
Security Assessments
Summary and Final Words
No crucial issues found
The contract does not contain issues of high or medium criticality. This means that no known vulnerabilities were found in the source code.
Contract owner cannot mint
It is not possible to mint new tokens.
Contract owner cannot blacklist addresses.
It is not possible to lock user funds by blacklisting addresses.
Contract owner cannot set high fees
The fees, if applicable, can be a maximum of 25% or lower. The contract can therefore not be locked. Please take a look in the comment section for more details.
Token transfer can be locked
Owner can lock user funds with owner functions.
Token cannot be burned
There is no burning within the contract without any allowances
Ownership is not renounced
The owner retains significant control, which could potentially be used to modify key contract parameters.
Contract is not upgradeable
The contract does not use proxy patterns or other mechanisms to allow future upgrades. Its behavior is locked in its current state.
Scope of Work
This audit encompasses the evaluation of the files listed below, each verified with a SHA-1 Hash. The team referenced above has provided the necessary files for assessment.
The auditing process consists of the following systematic steps:
- Specification Review: Analyze the provided specifications, source code, and instructions to fully understand the smart contract's size, scope, and functionality.
- Manual Code Examination: Conduct a thorough line-by-line review of the source code to identify potential vulnerabilities and areas for improvement.
- Specification Alignment: Ensure that the code accurately implements the provided specifications and intended functionalities.
- Test Coverage Assessment: Evaluate the extent and effectiveness of test cases in covering the codebase, identifying any gaps in testing.
- Symbolic Execution: Analyze the smart contract to determine how various inputs affect execution paths, identifying potential edge cases and vulnerabilities.
- Best Practices Evaluation: Assess the smart contracts against established industry and academic best practices to enhance efficiency, maintainability, and security.
- Actionable Recommendations: Provide detailed, specific, and actionable steps to secure and optimize the smart contracts.
A file with a different Hash has been intentionally or otherwise modified after the security review. A different Hash may indicate a changed condition or potential vulnerability that was not within the scope of this review.
Final Words
The following provides a concise summary of the audit report, accompanied by insightful comments from the auditor. This overview captures the key findings and observations, offering valuable context and clarity.
Smart Contract Analysis Statement
Contract Analysis
The Veilon protocol implements a privacy pool for ETH and ERC20 deposits backed by Groth16 zero-knowledge proofs, an emergency-exit ("panic button") flow with stealth-wallet redistribution, and an on-chain governance module. The fix cycle since the previous audit closed every originally reported critical and high finding, including the seven critical issues on EmergencyExit (no proof verification, on-chain key generation, missing fund movement, unbounded contact array, unreachable cancellation, missing public-input binding) and the previously reopened MEV / front-running vector on the redesigned two-phase panic button. A small number of items still need attention before mainnet:
- The two-phase panic button is now safe.
triggerPanicButtonrequires the user to commit the stealth-wallet array up front and storeskeccak256(abi.encodePacked(commitments))on the session, andexecuteRedistributionenforces two independent gates:msg.sender == session.user || authorizedRelayers[msg.sender]AND a re-check of the stealth-commitment hash. A front-runner replaying the user's public proof can neither call the function nor substitute attacker-controlled stealth wallets. - Cross-chain replay protection is only partially in place. The contract-level guard (immutable
deploymentChainIdwith anonlyDeploymentChainmodifier on every state-changing entry point) blocks fork-replay, but full cross-deployment protection still requires regenerating the ZK circuits withblock.chainidas the last public input and then enablingchainIdEnforcedper chain. Until that work is done, a proof for one chain can in principle be replayed on another chain that holds the same commitment. The current deploy script ships with_enforceChainIdInProofs=false, consistent with the legacy circuit layout. - The deployment pipeline is the remaining release-blocker.
scripts/deploy.jsnever callsprivacyPool.setAuthorizedContract(emergencyExit, true)after deploying EmergencyExit, so the panic button will silently revert atPrivacyPoolV2.redistributeCommitmentin production despite appearing to succeed intriggerPanicButton. The non-Sepolia scripts (deploy-arbitrum.js,deploy-local.js) and the--config hardhat.config.arbitrum.jsreferences inpackage.jsonstill target the pre-fix contract names and signatures and will fail before any bytecode is shipped. A post-deploy smoke check assertingauthorizedContracts[emergencyExit] == trueanddeploymentChainId == network.chainidshould also be added. - Operational control of all four contracts (PrivacyPoolV2, EmergencyExit, Verifier, Governance) is still concentrated in a single Ownable owner. The 48-hour timelock on emergency withdrawals reduces instant rug-pull risk, but moving ownership behind a multisig and a TimelockController - and migrating to
Ownable2Step- is the recommended next step before mainnet.
Ownership Privileges
Ownership of every deployed contract is currently held by a single Ownable owner (the deployer). Transfer to a multisig - ideally behind a TimelockController - is recommended before mainnet. The owner retains the following privileges:
- PrivacyPoolV2 - pause / unpause the pool, whitelist or remove supported tokens, authorize external contracts (e.g. EmergencyExit) to call
redistributeCommitment, toggle ZK chain-ID enforcement, and queue / execute / cancel emergency withdrawals. - EmergencyExit - pause / unpause, update the emergency fee, add or remove authorized relayers, and withdraw collected fees.
- Verifier - register, deregister, and update circuit parameters.
- Governance - register and deregister voters, update quorum and voting period, and execute passed proposals.
- The previous instant-drain
emergencyWithdrawhas been replaced with a 48-hour queue / execute / cancel timelock that emits events at every step, materially reducing rug-pull risk. - The panic-button redistribution is gated by a 15-minute cancellation window during which the user can abort the session, and the actual execution is restricted to the session owner or an explicitly authorized relayer - the contract owner cannot spend a user's session.
- The "random" redistribution strategy is disabled at the contract level (fail-closed), so the validator-influenceable RNG path can no longer be reached.
- Every owner-only setter that mutates security-critical state -
setAuthorizedContract,setChainIdEnforced,setTokenWhitelist,addAuthorizedRelayer,removeAuthorizedRelayer,updateEmergencyFee, the emergency-withdraw queue / execute / cancel triplet, and the Verifier circuit registry - emits a dedicated event, so off-chain monitors can detect every privileged change. - The owner cannot mutate
verifierContract, the underlying Groth16 verifier addresses, ordeploymentChainId- all are declaredimmutable.
Security Features
The contracts apply a number of solid defensive practices:
- OpenZeppelin
ReentrancyGuard,Pausable,SafeERC20, andOwnableare used throughout, withnonReentrantplaced first on every state-changing entry point exposed to external callers. - ZK verification calls real Groth16 verifiers generated by snarkJS, dispatched via a fixed-size
CircuitTypeenum, with calldata fields equality-checked against the proof's public inputs inunshield,privateTransfer,redistributeCommitment, andtriggerPanicButton. unshieldfollows strict checks-effects-interactions ordering: nullifier marking, commitment spending, change-commitment creation, and accounting all complete before the recipient transfer; events are emitted last.- The panic-button flow binds caller authority and stealth-wallet identity end-to-end: phase 1 stores
keccak256(abi.encodePacked(stealthCommitments))on the session, and phase 2 re-checks both the caller (session.useror authorized relayer) and the same hash, closing the previous MEV / front-running window. - The custom contracts pin
pragma solidity 0.8.19, declare every constructor-only reference asimmutable, validate zero-address inputs in constructors, and emit events on every security-critical state change.
Note - This audit report covers the Solidity contracts in the smartcontract-main repository (PrivacyPoolV2, EmergencyExit, Verifier, Governance, and the three snarkJS-generated Groth16 verifier wrappers). The economic and tokenomic dimensions of the Veilon protocol were not analyzed. Off-chain components - the relayer service, the mobile client, the backend, and the ZK circuits themselves - were not in scope and need to be audited separately. Integrators and users should perform their own due diligence before relying on the protocol in production.
Files and details
Findings and Audit result
critical Issues | 8 findings
Resolved
#1 critical Issue
Fundamental Design Flaw
The original triggerPanicButton and _executeRedistribution never actually transferred any ETH or tokens. The redistribution only created StealthWallet records in storage; no interaction with PrivacyPoolV2 occurred and no transfer / call / safeTransfer was invoked. The entire emergency-exit flow was a no-op for actual fund protection.
Resolved
#2 critical Issue
Privacy Violation
The original _generateStealthWallet generated viewingKey and spendingKey on-chain and stored them in a public mapping. getSessionStealthWallets returned all wallet data, including these keys, to any caller. In a stealth-address scheme viewing and spending keys must remain private; storing private key material on-chain in plaintext defeated the entire purpose of the privacy mechanism.
Resolved
#3 critical Issue
Incomplete Implementation
recoverStealthWallet validated the recovery proof and marked the wallet as recovered, but the actual fund transfer was never implemented - the original code contained only a comment placeholder. Even with a valid recovery proof, the user received nothing. This was an incomplete implementation deployed to testnet.
Resolved
#4 critical Issue
Missing Proof Verification
triggerPanicButton accepted a bytes calldata _proof labelled as a 'zero-knowledge proof of fund ownership', but never verified it - the proof was merely hashed and stored. The Verifier contract was referenced but never called. Anyone with an emergency config could trigger the panic button for any arbitrary _totalAmount without proving fund ownership.
Resolved
#5 critical Issue
Unbounded Array Growth / DoS
configureEmergencyExit pushed new emergency contacts onto config.emergencyContacts without ever clearing the existing array. Repeated calls accumulated duplicates, causing unbounded storage growth, inflating the gas cost of _notifyEmergencyContacts (which iterates the full array), and ultimately risking a denial-of-service if the array exceeded the block gas limit.
Resolved
#6 critical Issue
Unreachable Code
cancelEmergencySession required session.status == 1 (processing). However, the original triggerPanicButton called _executeRedistribution synchronously and immediately set session.status = 2 (completed) before the function returned. By the time triggerPanicButton finished, the session was already completed and could never be cancelled, making the EMERGENCY_TIMEOUT window meaningless.
Resolved
#7 critical Issue
Proof / Public Input Binding Failure
In unshield and privateTransfer the contract verified proof.publicInputs but did not enforce that the calldata arguments (input commitment, output / change commitments, output / change amounts, token, recipient) equalled the corresponding public inputs consumed by the circuit. This broke statement-to-state binding and could allow state transitions against mismatched on-chain commitments.
Resolved
#8 critical Issue
Access Control / Front-Running / Theft of Funds
After the two-phase redesign of the panic button, executeRedistribution (EmergencyExit.sol:264-292) is external nonReentrant whenNotPaused with no msg.sender == session.user check, no authorized-relayer check, and no requirement that the stealth commitments were committed to in phase 1. The only gates are session-pending, cancellation-window-expired, keccak256(abi.encode(_proof)) == session.proofHash, and a length match against strategy.numberOfWallets. The full _proof is publicly readable forever in the calldata of the original triggerPanicButton transaction. Concretely: once the 15-minute cancellation window closes, an attacker (or any MEV searcher) can call executeRedistribution with the user's already-public proof and their own stealth commitments. PrivacyPoolV2 marks the user's input commitment spent and creates new commitments at the attacker-supplied addresses; the attacker then unshields the funds. Even if the user tries to be the caller themselves, a competing transaction with the same proof and a higher priority fee will land first - a textbook MEV scenario. The redistributeCommitment primitive itself is fine; the failure is at the EmergencyExit layer, which gives any caller authority to spend the user's session.
high Issues | 2 findings
Resolved
#1 high Issue
Centralization / Rug Pull
The original emergencyWithdraw function let the owner withdraw any amount of any token (including ETH) with no time-lock, no multisig requirement, and no event emission. In a trust-minimized deployment this was a direct rug-pull vector: the owner could drain the entire pool instantly without warning users.
Pending
#2 high Issue
Deployment / Operational
The deployment pipeline is broken on every chain except Sepolia. package.json:11-12 references hardhat.config.arbitrum.js, which does not exist - both deploy:arbitrum-sepolia and deploy:arbitrum-mainnet, and the aggregate deploy:all-* scripts, fail before the JS runs. scripts/deploy-arbitrum.js targets contracts that no longer exist or have changed signatures: Verifier.deploy() with no args (constructor needs three), RelayerRegistry (no longer in contracts/), PrivacyPool (now PrivacyPoolV2), Governance with five args (now takes two), EmergencyExit with three args (now takes two), and a call to privacyPool.registerRelayer that no longer exists. scripts/deploy-local.js has the same drift. scripts/deploy.js produces working bytecode but never calls privacyPool.setAuthorizedContract(emergencyExit, true), so EmergencyExit.executeRedistribution will permanently revert at PrivacyPoolV2.redistributeCommitment's authorization check on every chain that uses it; the panic-button feature is silently broken even though triggerPanicButton appears to succeed and publishes the user's emergency proof in calldata. scripts/deploy.js also passes _enforceChainIdInProofs=false (line 59), so MED-02's ZK-binding mitigation is inactive on every deployment that uses this script.
medium Issues | 10 findings
Resolved
#1 medium Issue
Gas Waste / Design Flaw
verifyShieldProof, verifyUnshieldProof, and verifyTransferProof in Verifier.sol called this.verifyProof(...), forcing an external call back into the same contract. This wasted roughly 2600 gas per invocation, forced external instead of view visibility, and acquired the reentrancy lock on every wrapper call without a real reason.
Resolved
#2 medium Issue
Checks-Effects-Interactions Hardening
In the original unshield, the change-commitment creation and the totalUnshieldedByToken update happened after the external recipient transfer. Although nonReentrant prevented direct exploitation, the ordering still violated CEI best practices and increased the upgrade and integration risk.
Resolved
#3 medium Issue
Missing Input Validation
The PrivacyPoolV2 constructor accepted _verifierContract without checking that it was non-zero. A deployment with the zero address would have caused all unshield and privateTransfer calls to revert with opaque errors when calling the verifier. The Verifier constructor already had this check; PrivacyPoolV2 should be consistent.
Resolved
#4 medium Issue
Predictable Randomness
The 'random' distribution strategy (type 2) used keccak256(abi.encodePacked(i, _totalAmount)) as its randomness source. Both inputs are known to the caller, making the distribution deterministic and predictable. Even when block.prevrandao and block.timestamp were folded in, those values are validator-influenceable post-Merge. The function also lacked an underflow guard for the maxAmount <= minAmount case.
Resolved
#5 medium Issue
Missing Input Validation
The weighted distribution strategy (type 1) copied user-supplied amounts directly without verifying that their sum equalled _totalAmount. A user could specify amounts summing to more or less than the total, which would result in inconsistent accounting on-chain.
Resolved
#6 medium Issue
Architecture / Design Pattern
The Verifier dispatched verification requests by hashing string circuit identifiers ('shield', 'unshield'). String hashing is gas-inefficient and fragile compared to enum or selector-based dispatch, and string manipulation is an unnecessary source of risk for a security-critical router.
Resolved
#7 medium Issue
Hash Collision / Cache Key Integrity
verifyProof computed proofHash with abi.encodePacked over a dynamic-typed circuitType. abi.encodePacked over dynamic types can produce hash collisions across crafted packed inputs and poison the proof-cache accounting, even if it does not directly bypass Groth16 validity checks. abi.encode is the collision-safe canonical encoding.
Resolved
#8 medium Issue
Weak Randomness
_generateStealthWallet derived stealth-wallet key material on-chain using block.timestamp, block.prevrandao, and a contract-side nonce. Each of these is predictable or validator-influenceable, so a validator or proposer could predict or manipulate the generated stealth wallet addresses. Wallet parameters should be generated off-chain or via Chainlink VRF.
Resolved
#9 medium Issue
Deprecated Pattern
withdrawFees used payable(owner()).transfer(address(this).balance), which forwards only 2300 gas. If the owner is a multisig (e.g. Gnosis Safe) or any contract with a non-trivial receive function, the transfer reverts because 2300 gas is insufficient. The recommended replacement is .call{value: amount}(''). with a require check on the boolean return.
Resolved
#10 medium Issue
Stale Tests / Release Readiness
Two integration tests still use outdated function signatures against the current contracts. test/FullFlow.test.js:120-126 calls unshield with 5 args, but PrivacyPoolV2.unshield now takes 7. test/FullFlow.test.js:242-247 calls privateTransfer with 4 args; the contract now takes 7. test/FullFlow.test.js:312-318 has the same 5-arg unshield bug for userB. test/PrivateTransfer.test.js:207-212 has the same 4-arg privateTransfer bug. INFO-02 fixed the equivalent issue in PrivacyPoolV2.test.js, but the rewrite was not propagated to the rest of the suite.
low Issues | 12 findings
Resolved
#1 low Issue
Dead Code
TransferVerifier_NEW.sol contained a contract named Groth16Verifier with only 5 public inputs, while the actual TransferGroth16Verifier in TransferVerifier.sol requires 10. The file was unreferenced anywhere in the codebase and looked like leftover development scaffolding. Dead code increases the attack surface and creates confusion during audits.
Resolved
#2 low Issue
Missing Events
Several owner-only state-changing functions did not emit events, leaving off-chain monitoring blind to critical configuration changes - notably updateEmergencyFee, addAuthorizedRelayer, removeAuthorizedRelayer, configureEmergencyExit, cancelEmergencySession, the previous emergencyWithdraw, and Verifier.updateCircuit. Events on these state transitions are essential for indexers, alerting, and incident response.
Resolved
#3 low Issue
Gas Optimization / Immutability
Several state variables that were only set in the constructor were declared as regular storage. Declaring them immutable saves roughly 2100 gas per SLOAD and enforces that they cannot be changed after deployment - a useful invariant for security-critical references like the verifier contract.
Resolved
#4 low Issue
Modifier Ordering
Multiple functions placed nonReentrant after other modifiers in the modifier list. If any earlier modifier had a reentrancy issue, the guard would not protect against it. nonReentrant should be applied first so the lock is acquired before any other modifier logic runs.
Resolved
#5 low Issue
Unused State Variable
PrivacyPoolV2 maintained a Counters.Counter _transactionId that was incremented on every shield and on every commitment creation but was never read externally - no getter exposed it and no on-chain logic depended on it. Each increment cost an SSTORE for no functional benefit.
Resolved
#6 low Issue
Logic Error
The Voter struct in Governance had a hasVoted field that was never set to true after a voter cast a ballot. The getVoter view always returned false for this field, even when the voter had clearly voted. Per-proposal voting state was correctly tracked through Receipt.hasVoted, but the voter-level field was misleading.
Resolved
#7 low Issue
Logic / Semantics
The proposalPassed view returned false once a proposal had been executed. This was semantically misleading for integrators - an executed proposal has, by definition, passed. Either rename the function to indicate pending-execution state, or return the pass status independently of execution.
Resolved
#8 low Issue
Unspecific Pragma
All custom contracts used a floating pragma (^0.8.19), allowing compilation under any 0.8.x version at or above 0.8.19. Different compiler versions can produce different bytecode and have different bugs. Pinning the pragma for production deployments ensures deterministic compilation and clean source verification.
Resolved
#9 low Issue
Access Control
MockERC20.mint() and burn() had no access control. Although a test contract, it was placed alongside production code in the contracts/ directory, so an accidental mainnet deployment would have allowed anyone to mint unlimited tokens.
Resolved
#10 low Issue
Missing Events
Two owner-only setters introduced during the fix cycle change security-critical state without emitting events. setChainIdEnforced (PrivacyPoolV2.sol:140) toggles the cross-chain replay defense, and setAuthorizedContract (line 149) grants the privilege to call redistributeCommitment - the same privilege abused by NEW-01. Off-chain monitoring is currently blind to either change.
Resolved
#11 low Issue
Operational Bug
deploy.js stores the deployed pool address under deployedContracts.PrivacyPoolV2 (line 62), but updateEnvironmentFile reads contracts.PrivacyPool (line 197). The rendered env line therefore becomes PRIVACY_POOL_ADDRESS=undefined, silently breaking any downstream consumer (relayer, mobile app, backend) that depends on env.example.
Acknowledged
#12 low Issue
Cross-Chain Replay
ZK proof verification did not include block.chainid or any chain-specific identifier in the public inputs. Since the contracts are deployed on multiple chains (Sepolia, Arbitrum, BSC, Mumbai), a valid proof generated on one chain could be replayed on another chain where the same commitment exists. Either bind block.chainid into the ZK circuit's public inputs or include the deployment address in the nullifier derivation.
optimization Issues | 4 findings
Resolved
#1 optimization Issue
Gas Optimization
In _verifyProofByType, keccak256(abi.encodePacked(circuitType)) was computed up to three times to compare against 'shield', 'unshield', and 'transfer'. Each keccak256 over a string costs gas and the pattern was inherently unsafe compared to a fixed-size enum.
Resolved
#2 optimization Issue
Gas Optimization
In configureEmergencyExit, the loop used config.emergencyContacts.push() which performs SSTORE on every iteration, and re-read _contacts.length from calldata on every iteration. Caching the length and considering batched storage patterns reduces gas cost and avoids redundant calldata reads.
Resolved
#3 optimization Issue
Gas Optimization
PrivacyPoolV2 used a Counters.Counter _transactionId that was incremented on every shield and on every commitment creation. Each increment cost roughly 5000 gas (non-zero to non-zero SSTORE) without any external read of the value. Either remove it or replace it with a plain uint256 increment, which is cheaper than the Counters library.
Resolved
#4 optimization Issue
Redundant Storage
Commitment data was stored redundantly: once in the shieldedTransactions mapping (which already carries token, amount, etc.) and again in separate commitmentAmount and commitmentToken mappings. This doubled SSTORE costs on every shield operation.
informational Issues | 6 findings
Resolved
#1 informational Issue
Broken Test
The test labelled 'Should track total shielded correctly' called privacyPool.totalShielded(), a function that does not exist on the contract. The contract uses the totalShieldedByToken(token) mapping instead. The test was written against an older interface and was never updated.
Resolved
#2 informational Issue
Broken Test
The test labelled 'Should allow user to unshield ETH' called unshield with the old 5-parameter signature (nullifier, recipient, amount, token, proof). The current contract requires 7 parameters (inputCommitment, changeCommitment, recipient, outputAmount, changeAmount, token, proof), so the test never exercised the current interface.
Pending
#3 informational Issue
External Dependency
FullFlow.test.js and PrivateTransfer.test.js depend on a relayer service running at localhost:3004 which is not included in the repository. As a result these integration tests cannot be executed in isolation, which weakens the safety net for the most realistic end-to-end flows.
Pending
#4 informational Issue
Missing Test Coverage
Several critical code paths still have no test coverage: partial unshield with change commitment, private transfer with change, the emergency-withdraw queue/execute/cancel flow, all Governance flows (voting, execution, quorum), the redesigned EmergencyExit recovery and cancellation paths, access-control violation attempts, token de-whitelisting while funds are shielded, and reentrancy attack simulations.
Pending
#5 informational Issue
Multiple Solidity Versions
Four different Solidity version constraints are used across the project: ^0.8.19 in the custom contracts, ^0.8.0 and ^0.8.1 in OpenZeppelin, and >=0.7.0 <0.9.0 in the snarkJS-generated verifiers (ShieldVerifier.sol, UnshieldVerifier.sol, TransferVerifier.sol). The snarkJS verifiers in particular use a very wide pragma range; even though the code is auto-generated, pinning it tightens the build.
Pending
#6 informational Issue
Centralization Risk
All four contracts (PrivacyPoolV2, EmergencyExit, Verifier, Governance) rely on a plain Ownable owner. The owner can pause the pool, whitelist tokens, queue and execute emergency withdrawals, register and deregister voters, execute proposals, and register / deregister verifier circuits. This concentrates significant operational control in a single key and is the largest residual trust assumption in the system.