Download audit report
This document presents the security review performed by Nethermind Security for the smart contracts of Veggia. The audit scope comprises smart contracts for Veggia protocol, an NFT system that implements a unique "caps" mechanism for minting.
Any user can mint tokens under the free tier. There is a limit to the maximum number of tokens a user can hold at any point in time. Users can mint up to a max of 3 tokens in one cycle. There is a cooling period between cycles.
Users can also opt for paid minting. There are normal and premium cap plans to buy credits for minting. There is also a premium pack that entitles users with a defined set of normal and premium caps.
The minted NFT tokens have a royalty support and lock mechanism. Owners are allowed to burn their NFT tokens.
This security review focuses exclusively on the smart contracts listed in Section 2 (Audited Files).
The audited code comprises of 324 lines of code written in the Solidity language, and the audit was performed using (a) manual analysis of the code base and (b) creation of test cases.
| Audit assessment | |
| Audit Type | Security Review |
| Initial Report | Jan 31, 2025 |
| Response from Client | Regular responses during audit engagement |
| Final Report | April 03, 2025 |
| Repository | Veggia |
| Commit (Audit) | d44fc8e4c9 |
| Commit (Final) | 1ea7178c14 |
| Documentation Assessment | Low |
| Test Suite Assessment | Medium |
Findings by severity
| # | Contract | LoC | Comments | Ratio | Blank | Total |
| 1 | ERC721TransferLock.sol | 18 | 12 | 66.7% | 6 | 36 |
| 2 | VeggiaERC721.sol | 260 | 290 | 111.5% | 88 | 638 |
| 3 | proxy/VeggiaERC721Proxy.sol | 46 | 32 | 69.6% | 13 | 91 |
| Total | 324 | 334 | 103.1% | 107 | 765 |
| # | Finding | Severity | Update |
| 1 | Name and Symbol for VeggiaERC721 are not set | High | Fixed |
| 2 | VeggiaERC721 is vulnerable to signature replay attacks | High | Fixed |
| 3 | Tokens minted by smart contract could be stuck | Medium | Fixed |
| 4 | Storage collision in the Proxy contract | Low | Fixed |
| 5 | Users can get immediately all the free mint tokens without waiting for post contract deployment | Low | Acknowledged |
| 6 | Missing validation in setFreeMintCooldown function could lead to revert | Info | Fixed |
| 7 | Caps purchased with premiumPackPrice is inconsistent with comments in the contract | Info | Fixed |
| 8 | CapsPrice should be configurable only for multiples of 3 | Info | Fixed |
| 9 | Ownership is more secure with two-step transfers | Best Practices | Fixed |
| 10 | The constructor in VeggiaERC721 contract is redundant | Best Practices | Acknowledged |
| 11 | Unused custom error in VeggiaERC721 contract | Best Practices | Acknowledged |
Veggia protocol is an NFT-based system that leverages upgradeable proxy patterns to extend functionality with new deployment. It allows the users to mint NFT based on their entitlements. Every user is entitled to mint up to a maximum limit of holding at a given point. The system also enforces cooldown time down between mints for the free tier. Users can also purchase entitlements to mint tokens. zKVeggies supports normal caps, premium caps, and Premium packs as plans for buying entitlements to mint NFTs.

Anyone can mint tokens under the free tier. Users can mint NFTs using free caps that regenerate over time, limited by a cooldown period and maximum cap limit.
function freeMint3() external {
...
}Users can purchase normal and premium caps to mint NFTs. A special bundle containing 1 NFT, 12 regular caps, and 3 premium caps is also an option.
function buyCaps(bool isPremium, uint256 quantity) external payable { ... } function buyPremiumPack() external payable { ... } function mint3(bool isPremium) external { ... } Users can claim tokens if they are granted entitlements through a signed message.
function mint3WithSignature(bytes memory signature, bytes calldata message) external {
...
}The owner of the protocol can perform admin functions to configure the values in the protocol.
4.5.1 Inheriting from non-upgradeable contracts
When using a proxy pattern for upgradeability, the recommendation is to use the upgradeable version of OpenZeppelin contracts. These contracts are designed to minimize the potential for storage conflict by isolating the storage spaces for contracts in the hierarchy. The intent is to provision for upgradeability without conflicts.
It was observed that the VeggiaERC721 contract derives from a mix of upgradeable and non-upgradeable library contracts of OpenZeppelin and, hence has the potential for such risk. zKVeggies team is made aware of the risks.
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:
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:
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 / Likelihood | Low | Medium | High |
| High | Medium | High | Critical |
| Medium | Low | Medium | High |
| Low | Info / Best Practices | Low | Medium |
| Undetermined | Undetermined | Undetermined | Undetermined |
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.
| File | Fix commit | Type | Status |
VeggiaERC721.sol | PR #2 | High | Fixed |
VeggiaERC721 is implemented behind proxy contract. All the interactions of the functionality in VeggiaERC721 are routed through the proxy contract. As a result, the storage used by the protocol is that of a proxy contract.
In the constructor, the name and symbol for the contract as passed as part of the inheritance hierarchy. The constructor is only initializing the storage of VeggiaERC721 contract, which is a logic contract.
constructor(address _feeReceiver, string memory _baseUri) ERC721("Veggia", "VEGGIA") Ownable(msg.sender) {
baseURI = _baseUri;
feeReceiver = _feeReceiver;
}The actual storage is owned by proxy and hence name and symbol should be initialized in the initialize function. However, the latter does not initialise the name and the symbol:
function initialize(address _owner, address _feeReceiver, address _capsSigner, string memory _baseUri) external {
/// @dev Skips owner verification as the proxy is already ownable.
/// @dev Skips initialization check as the proxy handles the initialization check internally.
_transferOwnership(_owner);
baseURI = _baseUri;
capsSigner = _capsSigner;
feeReceiver = _feeReceiver;
// Must be a multiple of 3
freeMintLimit = 6;
freeMintCooldown = 12 hours;
// Prices
capsPriceByQuantity[3] = 0.0003 ether;
capsPriceByQuantity[9] = 0.0006 ether;
capsPriceByQuantity[30] = 0.0018 ether;
premiumCapsPriceByQuantity[3] = 0.0009 ether;
premiumCapsPriceByQuantity[9] = 0.00225 ether;
premiumCapsPriceByQuantity[30] = 0.0054 ether;
premiumPackPrice = 0.0036 ether;
// Set the default royalty to 0
_setDefaultRoyalty(feeReceiver, 0);
}Moreover, ERC721 does not provide an option to update these variables through set functions.
As a result, the name and symbol are never initialised for VeggiaERC721
Recommendation
Use upgradeable contracts that are designed for proxy patterns.
VeggiaERC721 is vulnerable to signature replay attacks| File | Fix commit | Type | Status |
VeggiaERC721.sol | PR #2 | High | Fixed |
The VeggiaERC721 contract supports offchain signatures for minting tokens. The mint3WithSignature function enables users to mint tokens using off-chain signature. However, the current implementation is vulnerable to replay attack if VeggiaERC721 is deployed on multiple chains.
function mint3WithSignature(bytes memory signature, bytes calldata message) external {
// Verify the signature
bytes32 messageHash = keccak256(message);
address recoveredSigner = ECDSA.recover(messageHash, signature);
if (recoveredSigner != capsSigner) revert INVALID_SIGNATURE();
// Decode the message
(address to, uint256 index, bool isPremium) = abi.decode(message, (address, uint256, bool));
// Check if the signature has already been used
if (signatureMintsOf[to][index]) revert SIGNATURE_REUSED();
// Check if the sender is the expected one
/// @dev Only the "to" address can use the signature
if (to != msg.sender) revert INVALID_SENDER(msg.sender, to);
// Mark the signature as used
signatureMintsOf[to][index] = true;
// Mint the NFTs
_open3CapsForSender(isPremium);
emit MintedWithSignature(msg.sender, message, signature);
}The current implementation provides a guard when VeggiaERC721 is deployed on a single chain. But, in case the contracts are deployed on multiple chains, using the same signature, the tokens can be minted on multiple chains.
Recommendation
Extend VeggiaERC721 from EIP712 which will ensure signatures cannot be replayed across chains. In order to be effective cross-chain, the correct domain initialization for VeggiaERC721 should be implemented.
| File | Fix commit | Type | Status |
VeggiaERC721.sol | PR #2 | Medium | Fixed |
Minting ERC721 tokens from a smart contract has a risk of tokens being stuck, in case the receiving contract does not have the ability to handle ERC721 tokens. Tokens locked in such contracts are not retrievable.
In order to prevent this, the recommended approach is to mint using _safeMint function. This function invokes hooks on the receiver to ensure the receiver can handle NFT tokens. It will proceed with the minting only for smart contracts that handle NFT tokens and hence prevent the tokens from being locked.
function _open3CapsForSender(bool isPremium) private {
//...
uint256 _tokenId = tokenId;
_mint(msg.sender, _tokenId);
_mint(msg.sender, _tokenId + 1);
_mint(msg.sender, _tokenId + 2);
//...
}Note: While _safeMint will prevent locking of the ERC721 token in a smart contract, it invokes a hook onERC721Received which can expose to reentrancy. So, when switching to _safeMint reentrancy guard should be applied.
Recommendation
Consider using _safeMint function with reentrancy guard.
| File | Fix commit | Type | Status |
VeggiaERC721.sol | PR #2 | Low | Fixed |
In proxy-based contract architectures, maintaining a correct storage layout is crucial for ensuring proper contract functionality. When using a sequential storage layout, storage slots are assigned in a rigid and incremental manner, which can lead to unintended storage overwrites between the proxy and the implementation contract.
In the VeggiaERC721Proxy contract, the initialized state variable is stored in slot 0:
contract VeggiaERC721Proxy is TransparentUpgradeableProxy {
/// @dev The initialization status of the contract.
bool initialized;
// --SNIP
}Meanwhile, the implementation contract, VeggiaERC721, follows a sequential storage layout and inherits from ERC721, where the first storage slot is used for storing the NFT's name:
contract VeggiaERC721 is ERC721, ERC721Burnable, ERC721TransferLock, ERC721Royalty, Ownable {}
abstract contract ERC721 is Context, ERC165, IERC721, IERC721Metadata, IERC721Errors {
using Strings for uint256;
string private _name;
// --SNIP
}Due to this layout mismatch, when the VeggiaERC721 contract initializes the _name variable, it writes to storage slot 0, which is already occupied by the initialized variable in the proxy contract. Consequently, this results in a direct override of the proxy contract's initialized state.
Fortunately, since the _name variable's byte representation will always be greater than 0x0, the initialized flag will not be unintentionally reset to false, preventing a critical oversight where the contract can be reinitialized. However, storage collisions like this are serious concerns as they may lead to unexpected behaviors in future upgrades. Moreover, if new state variables are introduced in the future, they may cause additional conflicts unless proper storage gaps are allocated in advance.
Recommendation
To prevent storage collisions, it is strongly recommended to use OpenZeppelin’s upgradable contract libraries. These libraries manage storage layouts explicitly and ensure that new storage variables can be safely added without causing conflicts. Additionally, when designing proxy-based contracts, explicitly reserving storage gaps (__gap patterns) can help accommodate future variables
| File | Fix commit | Type | Status |
VeggiaERC721.sol | — | Low | Acknowledged |
In VeggiaERC721 contract, the freeMint3 function allows the user to mint free tokens up to a limit. After they are minted, there is a cool down period which they have to wait before the next mint.
However, the current implementation of freeMint3 function allows the users to claim their first 6 caps without any wait post-contract deployment.
function freeMint3() external {
uint256 _freeMintLimit = freeMintLimit;
uint256 _freeMintCooldown = freeMintCooldown;
// Time elapsed since the last mint
==> uint256 elapsedTime = block.timestamp - lastMintTimestamp[msg.sender];
uint256 intervals = elapsedTime / _freeMintCooldown;
// Total accumulated caps balance
uint256 totalCaps = intervals * 3;
if (totalCaps > _freeMintLimit) {
totalCaps = _freeMintLimit;
}
//...
}The logic calculates the elapsed time as the difference between the current time and lastMintTimestamp of the users. As the default value for any account in lastMintTimestamp will be zero, every user will be able to claim their first set of free tokens without any wait for post-contract deployment.
Recommendation
One possible recommendation is to introduce a variable that tracks the time of the contract's deployment (set in the initialize function). During the first user's mint, consider calculating the elapsed time using the contract's deployment timestamp instead of lastMintTimestamp:
uint256 elapsedTime = lastMintTimestamp[msg.sender] == 0 ?
block.timestamp - deployedTimestamp :
block.timestamp - lastMintTimestamp[msg.sender];Resolution
It is a design choice to let the user mint tokens immediately the first time.
setFreeMintCooldown function could lead to revert| File | Fix commit | Type | Status |
VeggiaERC721.sol | PR #2 | Info | Fixed |
In setFreeMintCooldown function, there is no validation for an acceptable range of values for cool down time to unlock a new freeMint3. As a result, if it was set to 0, it will cause a revert during freeMint3 function call.
function freeMint3() external {
//...
uint256 _freeMintCooldown = freeMintCooldown;
//...
uint256 intervals = elapsedTime / _freeMintCooldown;
//...
}Likewise, many of the owner functions do not validate input values. Below are a few examples of such functions.
function setFreeMintCooldown(uint256 cooldown) external onlyOwner {
freeMintCooldown = cooldown; // can be set to 0
}
function setFeeReceiver(address receiver) external onlyOwner {
feeReceiver = receiver; // can be set to address(0)
}
function setCapsSigner(address _capsSigner) external onlyOwner {
capsSigner = _capsSigner; // can be set to address(0)
}Recommendation
Add validation that restricts acceptable range of values for freeMintCooldown state variable. Also, add validations to owner functions.
premiumPackPrice is inconsistent with comments in the contract| File | Fix commit | Type | Status |
VeggiaERC721.sol | PR #2 | Info | Fixed |
The caps purchased with premiumPackPrice have two different configurations per the comments in the contract. At the time of declaration of the state variable, the pack contains 1 NFT, 10 caps, and 3 premium caps.
* @notice The price of the premium pack.
* 1 NFT mint + 10 caps + 3 premium caps.
*/
uint256 public premiumPackPrice;But buyPremiumPack function mints 1 NFT, 12 caps and 3 premium caps.
/**
* @notice Buy a premium pack with the price of {premiumPackPrice}.
* @dev The premium pack contains 1 NFT mint + 12 caps + 3 premium caps.
*/
function buyPremiumPack() external payable {
if (msg.value != premiumPackPrice) revert WRONG_VALUE();
// Give the caps to the buyer (12 caps because 12 is a multiple of 3)
paidCapsBalanceOf[msg.sender] += 12;
// Give the premium caps to the buyer
paidPremiumCapsBalanceOf[msg.sender] += 3;
// Mint the NFT in the pack
uint256 _tokenId = tokenId; // Cache the tokenId in memory
_mint(msg.sender, _tokenId);
//....
}Recommendation
Update the natspec to reflect the actual caps minted for a user in a premium pack.
CapsPrice should be configurable only for multiples of 3| File | Fix commit | Type | Status |
VeggiaERC721.sol | PR #2 | Info | Fixed |
Since the buyCaps function only allows buying caps in multiples of 3, the configurable prices should also be restricted to multiples of 3.
Currently, there is no validation in the set functions:
function setCapsPrice(uint256 quantity, uint256 price) external onlyOwner {
capsPriceByQuantity[quantity] = price;
emit CapsPriceChanged(quantity, price);
}
function setPremiumCapsPrice(uint256 quantity, uint256 price) external onlyOwner {
premiumCapsPriceByQuantity[quantity] = price;
emit PremiumCapsPriceChanged(quantity, price);
}So, the owner can potentially configure any quantity, but the user can only buy in multiples of 3.
function buyCaps(bool isPremium, uint256 quantity) external payable {
if (quantity % 3 != 0) revert WRONG_CAPS_QUANTITY();
//...
}Recommendation
Implement validation similar to the buyCaps function in the setCapsPrice and setPremiumCapsPrice functions.
| File | Fix commit | Type | Status |
VeggiaERC721.sol | PR #2 | Best Practices | Fixed |
As the owner is a critical role in managing admin functions of the protocol, it is recommended to implement a two-step transfer process. In the first step, the current owner initiates the process for the new account to take over ownership. As a second step, the new account will claim ownership. This ensures that the caller of the claiming function has control of the new account.
contract VeggiaERC721 is ERC721, ERC721Burnable, ERC721TransferLock, ERC721Royalty, Ownable {Recommendation
Consider using Ownable2Step library contract of openzeppelin instead of Ownable.
VeggiaERC721 contract is redundant| File | Fix commit | Type | Status |
VeggiaERC721.sol | PR #2 | Best Practices | Acknowledged |
The implementation of the constructor in VeggiaERC721 is incorrect and redundant. For the proxy pattern, the storage variable should be initialized only via the initialize function so that the storage in the proxy is set.
The below implementation of the constructor initializes the storage on the logic contract, which will not be used by the protocol.
constructor(address _feeReceiver, string memory _baseUri) ERC721("Veggia", "VEGGIA") Ownable(msg.sender) {
baseURI = _baseUri;
feeReceiver = _feeReceiver;
}The initialize function below correctly sets the storage in proxy as required for the protocol.
function initialize(address _owner, address _feeReceiver, address _capsSigner, string memory _baseUri) external {
//...
baseURI = _baseUri;
//...
feeReceiver = _feeReceiver;
//...
}Recommendation
update the constructor as below:
constructor() {
_disableInitializers();
}VeggiaERC721 contract| File | Fix commit | Type | Status |
VeggiaERC721.sol | — | Best Practices | Acknowledged |
The VeggiaERC721 contract defines a custom error that is never used.
error ALREADY_INITIALIZED();This unused error definition increases contract size and gas costs unnecessarily while providing no functional benefit to the contract.
Recommendation
Remove the unused error from the contract.
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:
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.
The Veggia team was actively present in regular calls, effectively addressing concerns and questions raised by the Nethermind Security team. Additionally, the code includes NatSpec documentation for different functions and their parameters. However, the project documentation could be improved by providing a more comprehensive written overview of the system and an explanation of the different design choices.
All the relevant issues raised by Slither have been incorporated into the issues described in this report.
All the relevant issues raised by the AuditAgent have been incorporated into this report. The AuditAgent is an AI-powered smart contract auditing tool that analyses code, detects vulnerabilities, and provides actionable fixes. It accelerates the security analysis process, complementing human expertise with advanced AI models to deliver efficient and comprehensive smart contract audits. Available at https://app.auditagent.nethermind.io.
| 62Tests passed | 0Failed |
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.
The Leading Engineers of Blockchain Infrastructure