Veggia
NFT Minting

Covered Veggia's free and paid minting tiers, cap plans, and NFT royalty and burn mechanics.
report id
0209
category
Infra & Wallets
language
Solidity
final report
March 18, 2025
lines of code
324
Content

Download audit report

1

Executive Summary

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 TypeSecurity Review
Initial ReportJan 31, 2025
Response from ClientRegular responses during audit engagement
Final ReportApril 03, 2025
RepositoryVeggia
Commit (Audit)d44fc8e4c9
Commit (Final)1ea7178c14
Documentation AssessmentLow
Test Suite AssessmentMedium
Total findings
11
Fixed
8
Acknowledged
3
Mitigated
0
Unresolved
0

Findings by severity

  • Critical0
  • High2
  • Medium1
  • Low2
  • Informational3
  • Best Practices3

2

Audited Files

#ContractLoCCommentsRatioBlankTotal
1ERC721TransferLock.sol181266.7%636
2VeggiaERC721.sol260290111.5%88638
3proxy/VeggiaERC721Proxy.sol463269.6%1391
Total324334103.1%107765

4

System Overview

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.

Veggia overview
Fig. 2: Veggia overview

4.1 Free Minting

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 {
...
}

4.2 Paid Minting

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.

  • buyCaps: Users can buy either normal or premium caps using this method. Users can only buy in the multiples of 3. The choice for normal or premium caps is based on the isPremium flag. The funds are paid through native tokens. This function adds entitlements to mint.
    function buyCaps(bool isPremium, uint256 quantity) external payable { ... } 
  • buyPremiumPack: Using this function, the user can purchase the special bundle that contains 1 NFT, 12 regular caps, and 3 premium. This function adds entitlements to mint. caps
    function buyPremiumPack() external payable { ... } 
  • mint3: User can mint their entitled tokens with this function.
     function mint3(bool isPremium) external { ... } 

4.3 Signature base minting

Users can claim tokens if they are granted entitlements through a signed message.

function mint3WithSignature(bytes memory signature, bytes calldata message) external {
...
}

4.4 Owner functions

The owner of the protocol can perform admin functions to configure the values in the protocol.

4.5 Additional Observations

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.

5

Risk Rating Methodology

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:

  1. High: the issue is trivial to exploit and has no specific conditions that need to be met;
  2. Medium: the issue is moderately complex and may have some conditions that need to be met;
  3. Low: the issue is very complex and requires very specific conditions to be met.

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:

  1. High: the issue can cause significant damage, such as loss of funds or the protocol entering an unrecoverable state;
  2. Medium: the issue can cause moderate damage, such as impacts that only affect a small group of users or only a particular part of the protocol;
  3. Low: the issue can cause little to no damage, such as bugs that are easily recoverable or cause unexpected interactions that cause minor inconveniences.

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 / LikelihoodLowMediumHigh
HighMediumHighCritical
MediumLowMediumHigh
LowInfo / Best PracticesLowMedium
UndeterminedUndeterminedUndeterminedUndetermined

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.

  1. Informational findings do not pose any risk to the application, but they carry some information that the audit team intends to pass to the client formally;
  2. Best Practice findings are used when some piece of code does not conform with smart contract development best practices;
  3. Undetermined findings are used when we cannot predict the impact or likelihood of the issue.

6

Issues

6.1 Name and Symbol for VeggiaERC721 are not set

FileFix commitTypeStatus
VeggiaERC721.solPR #2HighFixed

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.

6.2 VeggiaERC721 is vulnerable to signature replay attacks

FileFix commitTypeStatus
VeggiaERC721.solPR #2HighFixed

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.

6.3 Tokens minted by smart contract could be stuck

FileFix commitTypeStatus
VeggiaERC721.solPR #2MediumFixed

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.

6.4 Storage collision in the Proxy contract

FileFix commitTypeStatus
VeggiaERC721.solPR #2LowFixed

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

6.5 Users can get immediately all the free mint tokens without waiting for post contract deployment

FileFix commitTypeStatus
VeggiaERC721.sol—LowAcknowledged

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.

6.6 Missing validation in setFreeMintCooldown function could lead to revert

FileFix commitTypeStatus
VeggiaERC721.solPR #2InfoFixed

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.

6.7 Caps purchased with premiumPackPrice is inconsistent with comments in the contract

FileFix commitTypeStatus
VeggiaERC721.solPR #2InfoFixed

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.

6.8 CapsPrice should be configurable only for multiples of 3

FileFix commitTypeStatus
VeggiaERC721.solPR #2InfoFixed

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.

6.9 Ownership is more secure with two-step transfers

FileFix commitTypeStatus
VeggiaERC721.solPR #2Best PracticesFixed

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.

6.10 The constructor in VeggiaERC721 contract is redundant

FileFix commitTypeStatus
VeggiaERC721.solPR #2Best PracticesAcknowledged

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();
}

6.11 Unused custom error in VeggiaERC721 contract

FileFix commitTypeStatus
VeggiaERC721.sol—Best PracticesAcknowledged

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.

7

Documentation Evaluation

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:

  • Technical whitepaper: A technical whitepaper is a comprehensive document describing the smart contract’s design and technical details. It includes information about the purpose of the contract, its architecture, its components, and how they interact with each other;
  • User manual: A user manual is a document that provides information about how to use the smart contract. It includes step-by-step instructions on how to perform various tasks and explains the different features and functionalities of the contract;
  • Code documentation: Code documentation is a document that provides details about the code of the smart contract. It includes information about the functions, variables, and classes used in the code, as well as explanations of how they work;
  • API documentation: API documentation is a document that provides information about the API (Application Programming Interface) of the smart contract. It includes details about the methods, parameters, and responses that can be used to interact with the contract;
  • Testing documentation: Testing documentation is a document that provides information about how the smart contract was tested. It includes details about the test cases that were used, the results of the tests, and any issues that were identified during testing;
  • Audit documentation: Audit documentation includes reports, notes, and other materials related to the security audit of the smart contract. This type of documentation is critical in ensuring that the smart contract is secure and free from vulnerabilities.

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.

8

Remarks & Test Suite

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.

Test suite
62Tests passed0Failed

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.