WilderWorld
Staking & Governance

Covered the staking contracts (locked and unlocked modes), voting-token minting on stake, and the ZDAO governance.
report id
0397
category
Token & RWA
language
Solidity
final report
March 13, 2025
lines of code
1093
Content

Download audit report

1

Executive Summary

This document presents the security review performed by Nethermind Security for the smart contracts of WilderWorld. The audit scope comprises of smart contracts related to Staking, Voting, and DAO smart contracts.

The staking contracts allow users to stake ERC20 or ERC721 tokens in locked or unlocked mode. In locked mode, the user's tokens are held for a fixed period, earning a higher yield. Conversely, unlocked mode offers more flexibility, allowing users to withdraw their funds at any time.

Upon staking, the protocol mints stake representation tokens, which serve as voting tokens for participation in ZDAO governance. The ZDAO governance is built based on OpenZeppelin’s governance contracts.

The audited code comprises 1093 lines of code written in the Solidity language. This security review focuses exclusively on the smart contracts listed in Section 2 (Audited Files).

The audit was performed using (a) manual analysis of the codebase and (b) creation of test cases.

Audit assessment
Audit TypeSecurity Review
Initial ReportJan 20, 2025
Response from ClientRegular responses during audit engagement
Final ReportMar 13, 2025
RepositoryzModules
Commit (Audit)97e93a1979
Commit (Final)f7a82d2853
Documentation AssessmentMedium
Test Suite AssessmentMedium
Total findings
21
Fixed
20
Acknowledged
1
Mitigated
0
Unresolved
0

Findings by severity

  • Critical1
  • High3
  • Medium2
  • Low1
  • Informational9
  • Best Practices5

2

Audited Files

#ContractLoCCommentsRatioBlankTotal
1voting/ZeroVotingERC20.sol572645.6%1093
2voting/ZeroVotingERC721.sol1113632.4%26173
3voting/IZeroVotingERC20.sol2214.5%629
4voting/IZeroVotingERC721.sol26519.2%1344
5dao/ZDAO.sol1894825.4%15252
6staking/StakingBase.sol19815477.8%55407
7staking/IStakingBase.sol84101120.2%37222
8staking/ERC20/IStakingERC20.sol2122104.8%1255
9staking/ERC20/StakingERC20.sol1498557.0%44278
10staking/ERC721/StakingERC721.sol1999748.7%50346
11staking/ERC721/IStakingERC721.sol373389.2%1787
Total109360855.6%2851986

4

System Overview

WilderWorld consists of a collection of modular smart contracts, organized into three core functional areas: Staking, Voting tokens and a DAO module. The following sections provide a detailed explanation of the interactions within these modules.

WilderWorld overview
Fig. 2: WilderWorld overview

4.1 Staking

The staking module enables users to stake either ERC20 or ERC721 tokens through the StakingERC20 and StakingERC721 contracts, respectively. Upon staking, users receive a representative token that allows them to participate in the protocol's governance.

4.1.1 Staking via StakingERC20

Users can stake ERC20 or native tokens into the StakingERC20 contract. Upon staking, an equivalent amount of a representative token is minted for the user. Staking can occur in two modes:

  • Locked mode: In locked mode, the deposited tokens are locked for a defined period. The longer the lock, the higher the reward rate. Rewards are pre-computed and recorded at the time of staking. Users can stake with a lock using the stakeWithLock(...) function.
    function stakeWithLock(uint256 amount, uint256 lockDuration) external payable override
  • Non-locked mode: Unlocked mode allows users to stake tokens without locking them for a pre-defined period. Rewards are earned based on the duration between the deposit and unstake time. This is possible via the stakeWithoutLock(...) function.
    function stakeWithoutLock(uint256 amount) external payable override

4.1.2 Staking via StakingERC721

The StakingERC721 contract facilitates the staking of ERC721 tokens. Similar to ERC20 staking, users can stake with or without a lock. Upon staking an ERC721 token, a representative ERC721 token with the same tokenId is minted for the user.

  • The stakeWithLock(...) function allows ERC721 staking with a lock.
    function stakeWithLock(uint256[] calldata tokenIds, string[] calldata tokenUris, uint256 lockDuration) external override 
  • The stakeWithoutLock(...) function allows ERC721 staking without a lock.
    function stakeWithoutLock(uint256[] calldata tokenIds, string[] calldata tokenUris) external override 

4.1.3 Claiming Rewards

Rewards are calculated based on the amount staked, the staking duration, and the staking mode. The reward formula is:

rewards = rewardsMultiplier * amount * config.rewardsPerPeriod * timeDuration / config.periodLength / divisor

The accrued rewards are tracked in the contracts based on the mode of staking. While the rewards for unlocked mode are claimable at any time, the rewards for locked mode will become available only after the lock period expires. Users can claim their accrued rewards via the claim(...) function.

function claim() external override

4.1.4 Unstaking

Users can unstake their tokens by burning the representative token and receiving the staked amount. Rewards are calculated and transferred accordingly.

In the case of locked tokens, unstaking is only allowed after the lock period. However, if users wish to exit early, they can set the exit flag to true, which will force unstaking but invalidate accrued rewards. This behavior is consistent across both ERC20 and ERC721 staking. Note that in the latest code, the exit functionality has been implemented in a separate exit(...) function.

function exit(bool locked) external override nonReentrant

function exit(uint256[] memory _tokenIds, bool _locked) public override nonReentrant

For the StakingERC20 contract, users can unstake non-locked funds using the unstake(...) function, renamed into unstakeUnlocked(...) in the latest version of the code.

function unstakeUnlocked(uint256 amount) external override

For locked funds, the unstakeLocked(...) function is used. This function reverts if funds are still within their lock period.

function unstakeLocked(uint256 amount) external payable override

For the StakingERC721 contract, both locked and unlocked tokens can be unstaked using the unstake(...) function. This function was split in the latest code into two different functions similar to the StakingERC20 contract:

function unstakeLocked(uint256[] memory _tokenIds) public override nonReentrant

function unstakeUnlocked(uint256[] memory _tokenIds) public override nonReentrant

4.2 Voting Module

The protocol supports voting for both ERC20 and ERC721 tokens. Users can delegate their voting rights to others, and voting tokens are non-transferable. The following contracts facilitate the voting functionality:

  • The ZeroVotingERC20 contract implements an ERC20 voting token.
  • The ZeroVotingERC721 contract implements an ERC721 voting token.

4.3 ZDAO

The protocol’s ZDAO contract, built using OpenZeppelin governance modules, enables eligible users to create proposals, vote on them, and delegate votes to others. Proposals that meet the required threshold are executed and incorporated into the protocol’s operations.

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 Unstaking with exit allows unauthorized rewards claim

FileFix commitTypeStatus
StakingERC721.sole327e43CriticalFixed

In the current implementation, when a user initiates an unstake operation with the exit flag set to true for a token that is still locked, the system behaves as expected: it unstakes the token but does not transfer rewards to the user. However, it fails to properly reset the owedRewardsLocked variable, which holds the pre-computed reward amount for the entire lock duration.

function _unstakeMany(...) internal {
// ...
for (i; i < _tokenIds.length;){
// ...
if (nftStaker.locked[_tokenIds[i]]) {
// ...
if (_getRemainingLockTime(nftStaker.stake) == 0) {
// ...
nftStaker.stake.owedRewardsLocked = 0; // @audit reset only when claimed
rewardsGivenLocked = true;
}
// ...
_unstake(_tokenIds[i]);
// ...
}
// ...
}
// ...
if (!exit) {
// Transfer the user's rewards
_transferAmount(config.rewardsToken, rewards);
emit Claimed(msg.sender, rewards);
}
}

As a result, the user can claim these rewards via the claim() function in the case of a partial unstake. Alternatively, the user can make another stake to access these rewards. This allows malicious users to exploit the system by creating stakes with long lock durations, exiting the stake early, and then claiming pre-computed rewards, potentially depleting the contract's funds.

Recommendation

The owedRewardsLocked value should be reset when unstaking locked funds.

6.2 Potential reward manipulation due to incorrect reward multiplier

FileFix commitTypeStatus
StakingBase.sole7ec0daHighFixed

In the _coreStake(...) function, when a user stakes a new amount with a locking period while their previous lock has not yet expired, the new stake is only locked for the remaining time of the previous lock. However, the rewards for the new stake are still calculated using the previous lock's rewardsMultiplier, which was intended for the entire previous lock duration. This results in the new stake receiving a disproportionately high rewards multiplier based on the full original lock period, even though it is only locked for the remaining time.

function _coreStake(...) internal {
// ...
if (block.timestamp > staker.unlockedTimestamp) {
// ...
} else {
staker.owedRewardsLocked += _getStakeRewards(
amount,
staker.rewardsMultiplier, //@audit multiplier for the previous lock duration
_getRemainingLockTime(staker),
true);
}
}

This issue can be exploited by users who stake large amounts near the end of their previous lock period. They can benefit from a higher rewards multiplier based on the full lock duration despite their new stake being locked for a much shorter period.

Recommendation

The rewardsMultiplier should be recomputed to reflect the remaining lock time for the new stake rather than relying on the multiplier from the previous lock.

6.3 Staking parameters change impact past rewards computations

FileFix commitTypeStatus
StakingBase.soldd2a9ecHighFixed

The owner of the staking contracts has the ability to modify configuration values that impact reward calculations, notably:

uint256 rewardsPerPeriod
uint256 periodLength
uint256 minimumRewardsMultiplier
uint256 maximumRewardsMultiplier

However, in the current design, changes to these parameters impact rewards computation for historical stakes and not the newest ones only.

For example, if a user stakes tokens and the owner later changes the reward parameters during the staking period ( updates the rewardsPerPeriod, for example), the system currently recalculates all previous rewards using the new configuration. This leads to a jump in accrued rewards, where rewards from earlier periods are either over- or under-calculated, depending on the direction of the change.

function _getStakeRewards(...) internal view returns (uint256) {
uint256 divisor = locked ? LOCKED_PRECISION_DIVISOR : PRECISION_DIVISOR;

return rewardsMultiplier * amount * config.rewardsPerPeriod * timeDuration / config.periodLength / divisor;
}

Recommendation

Ensure that changes to configuration values should only apply to rewards from the time they are updated and should not affect rewards for past periods. Alternatively, all user rewards must be accounted for and stored before performing any parameter update.

6.4 User loses rewards when unstaking partial amounts

FileFix commitTypeStatus
StakingERC20.sol
StakingERC721.sol
96191f2HighFixed

When a user unstakes a portion of their tokens, the contract computes rewards based on the withdrawn amount rather than the total amount staked. Refer to the code snippet below, where accrued rewards are being computed using the amount withdrawn.

function _unstake(uint256 amount, bool locked, bool exit) internal {
// ...
// most recent timestamp?
rewards = staker.owedRewards + _getStakeRewards(
amount, //@audit only rewards for unstaked amount are taken
1, // Rewards multiplier
block.timestamp - staker.lastTimestamp,
false
);
if (staker.amountStaked == amount) {
...
} else {
staker.amountStaked -= amount;
staker.lastTimestamp = block.timestamp;
}
// ...
}

After the partial unstake, the contract updates the staker.lastTimestamp to the current block.timestamp, considering that the entire staking period rewards are accounted for. Consequently, users lose the accrued rewards for the remaining staked amount.

Note that the present issue applies to both the ERC20 and ERC721 staking contracts, including both locked and non-locked tokens.

Recommendation

Use the total staked amount instead of the withdrawn amount when calculating the accrued rewards. For ERC20Staking, the staker.amountStaked can be used, while in ERC721Staking, the number of staked tokens must be accounted for.

6.5 Array length manipulation allows partial exit in _unstakeMany(...)

FileFix commitTypeStatus
StakingERC721.solb18e44fMediumFixed

The staking process is designed to enforce that a user must perform a full exit when unstaking. In the StakingERC721 case, this is enforced by the following check in the _unstakeMany(...) function.

if (exit && _tokenIds.length < nftStaker.stake.amountStakedLocked) {
revert NotFullExit();
}

However, the function currently allows users to submit arbitrary or invalid token IDs. These invalid IDs are skipped during processing, but they are still counted in the array length. As a result, users can manipulate the array length to bypass the full exit check and perform a partial exit.

if (
nftStaker.staked[_tokenIds[i]] == false
|| IERC721(config.stakeRepToken).ownerOf(_tokenIds[i]) == address(0)
|| IERC721(config.stakeRepToken).ownerOf(_tokenIds[i]) != msg.sender
) {
// Either the list of tokenIds contains a non-existent token
// or it contains a token the owner doesnt own
unchecked {
++i;
}
continue;
}

Recommendation

Update the function logic to avoid relying on the array length, as it can be manipulated.

6.6 Automatic re-locking of unlocked tokens and rewards in staking flow

FileFix commitTypeStatus
StakingBase.solf9201fdMediumFixed

Currently, when an existing user stakes additional tokens in locked mode and the previous lock duration has expired, the system automatically includes the previously unlocked amount as part of the new staked and locked amount.

From the user's perspective, the initially locked tokens are unlocked, and their rewards become claimable. However, if the user stakes additional tokens, the previously unlocked tokens are re-locked for a new period, alongside the new tokens. As a result, the previously accumulated rewards are also locked again. If the user attempts to exit during this new lock period, they risk losing the rewards earned during the previous lock period, even though the original lock was successfully completed.

function _coreStake(
Staker storage staker,
uint256 amount,
uint256 lockDuration
) internal {
if (lockDuration == 0) {
// ...
} else {
if (block.timestamp > staker.unlockedTimestamp) {
// The user has never locked or they have and we are past their lock period

// get the user's owed rewards from period in between `unlockedTimestamp` and present at rate of 1
uint256 mostRecentTimestamp = _mostRecentTimestamp(staker);

// this will return 0 if `amountStakedLocked` == 0
staker.owedRewardsLocked += _getStakeRewards(
staker.amountStakedLocked,
1,
block.timestamp - mostRecentTimestamp,
false
);

// Then we update appropriately
staker.unlockedTimestamp = block.timestamp + lockDuration;
staker.rewardsMultiplier = _calcRewardsMultiplier(lockDuration);

// We precalculate the amount because we know the time frame
staker.owedRewardsLocked += _getStakeRewards(
amount,
staker.rewardsMultiplier,
lockDuration,
true
);
} else {...}

staker.lastTimestampLocked = block.timestamp;
//@audit the cumulative amount is locked
staker.amountStakedLocked += amount;
}
}

Recommendation

Consider allowing users to unstake and claim their locked rewards once the timelock has expired. Alternatively, document the current process clearly to avoid user mistakes that could result in the locking or loss of accrued rewards.

6.7 Stale locked state might lead to reward discrepancies

FileFix commitTypeStatus
StakingERC721.sol53967b6LowFixed

The nftStaker.locked field is set during staking and stores whether a token Id is locked or not.

During the unstaking process of a locked token, this field is not reset to false.

function _unstakeMany(...) internal {
// ...
for (i; i < _tokenIds.length;){
// ...
if (nftStaker.locked[_tokenIds[i]]) {
// ...
_unstake(_tokenIds[i]);
--nftStaker.stake.amountStakedLocked;
nftStaker.staked[_tokenIds[i]] = false;
isAction = true;
}
// ...
}
// ...
}

As a result, if the same token is restaked without a lock, the stale locked entry remains true. This creates an inconsistency where the token is marked as locked in the system, but no rewards are actually computed for the locked stake.

Recommendation

Ensure that the nftStaker.locked flag is properly reset to false after a token is unstaked.

6.8 Absence of reentrancy protection in staking contracts

FileFix commitTypeStatus
StakingBase.sol889de33InfoFixed

The StakingBase contract inherits from OpenZeppelin's ReentrancyGuard to prevent reentrancy vulnerabilities. However, for this protection to work, functions within the StakingBase contract and its derived contracts must be explicitly marked with the nonReentrant modifier.

Currently, the nonReentrant modifier has not been applied to any functions in the StakingBase contract nor in its derived contracts (StakingERC20 and StakingERC721).

Recommendation

Assign the nonReentrant modifier to the necessary staking functions.

6.9 Duplicated functions to receive native tokens

FileFix commitTypeStatus
StakingBase.sole9474a1
491a40a
InfoFixed

In the current implementation, both the receive() and fallback() functions are defined, but both have empty logic. The receive() function is designed to handle native token transfers without data, while the fallback() function can handle transfers with or without data.

Since both functions serve the same purpose — allowing the contract to receive native tokens — and neither contains any additional logic, only one function is necessary.

Recommendation

Remove the redundant fallback() function.

6.10 Incorrect setup of Multiplier for locked rewards could prevent staking

FileFix commitTypeStatus
StakingBase.sol5436a2aInfoFixed

The locked rewards are calculated based on reward multipliers and the duration for which the tokens are locked. One of the factors for lock rewards calculation is the difference between maximumRewardsMultiplier and minimumRewardsMultiplier configured by the owner of the staking contracts.

function _calcRewardsMultiplier(uint256 lock) internal view returns (uint256) {
return config.minimumRewardsMultiplier
+ (config.maximumRewardsMultiplier - config.minimumRewardsMultiplier)
* (lock)
/ config.periodLength;
}

However, the setter functions for these parameters lack validation to ensure that maximumRewardsMultiplier is already greater than minimumRewardsMultiplier. Setting invalid values will prevent users from staking tokens for a locked duration. That is because when the _calcRewardsMultiplier(...) function is called during staking, an underflow exception will occur.

Likewise, the function reverts when the periodLength is set to 0.

Recommendation

Add validations in setMinimumRewardsMultiplier and setMaximumRewardsMultiplier setter functions to enforce that maximumRewardsMultiplier is always greater than minimumRewardsMultiplier.

Additionally, ensure that periodLength is always greater than 0 to avoid potential reverts.

6.11 Incorrect total supply of tokens returned by ZeroVotingERC721

FileFix commitTypeStatus
ZeroVotingERC721.sol5f0a8b9InfoFixed

The total supply of tokens in ZeroVotingERC721 contract is tracked using the _totalSupply state variable. The total supply should reflect the number of tokens in circulation after excluding the tokens that were burnt.

The current implementation of ZeroVotingERC721 contract increments the _totalSupply state variable when minting the new tokens through mint(...) and safeMint(...) functions, but it is not decremented when tokens are burnt through burn(...) function.

Therefore, _totalSupply does not correctly reflect the number of tokens currently in circulation.

Recommendation

The _totalSupply state variable should be decremented when a token is burnt to reflect the total number of tokens in circulation correctly.

6.12 Unnecessary payable operator for unstaking functions

FileFix commitTypeStatus
StakingERC20.sol8bd9636InfoFixed

The payable operator is unnecessary for unstaking functions in StakingERC20 contract. These functions do not require any native token payment.

function unstake(uint256 amount, bool exit) external payable override {
_unstake(amount, false, exit);
}

function unstakeLocked(uint256 amount, bool exit) external payable override {
_unstake(amount, true, exit);
}

Recommendation

Remove the payable operator from the above functions.

6.13 Unstaking non-locked tokens with exit cancels rewards

FileFix commitTypeStatus
StakingERC20.sol
StakingERC721.sol
—InfoAcknowledged

The staking contract allows users to stake tokens in both locked and unlocked modes. In locked mode, users earn higher rewards, but the staked tokens cannot be withdrawn before the end of the lock period. If a user attempts to exit from a locked stake, they forfeit all rewards. However, in unlocked mode, users have the flexibility to withdraw their stake at any time without forfeiting their rewards.

The current implementation of the StakingERC20 contract incorrectly implements this logic. When unstaking, if the user sets the exit flag to true, rewards are lost even in non-locked mode.

function _unstake(uint256 amount, bool locked, bool exit) internal {
// ...
if (locked) {...}
else { // @audit non-locked mode
if (amount > staker.amountStaked) {
revert UnstakeMoreThanStake();
}

if (exit) {
rewards = 0; //@audit rewards are removed
staker.owedRewards;
} else {...}
// ...
}
// ...
}

This issue is also present in the unstaking of ERC721 tokens.

Recommendation

Revise the logic to stop forfeiting rewards in unlocked mode. The exit flag should be applicable only to the locked mode of staking.

Update from Nethermind Security

The Wilderworld team confirmed during a call that this behavior is intentional. The exit flow has been restructured as a separate function, allowing users to withdraw only their initially staked tokens. This new functionality is particularly useful in cases where the contract is unable to provide reward tokens.

6.14 Unused ERC20Permit implementation in ZeroVotingERC20

FileFix commitTypeStatus
ZeroVotingERC20.sol7f7c8bfInfoFixed

The ZeroVotingERC20 contract inherits from the ERC20Permit contract but restricts token transfers through the _update() function, which is intended to prevent transfers of tokens. The ERC20Permit implementation introduces the permit(...) function that allows users to approve token transfers using signed messages. However, since the ZeroVotingERC20 contract explicitly disables token transfers, the permit(...) function becomes non-functional and, therefore, inheriting from the ERC20Permit contract is redundant.

function _update(
address from,
address to,
uint256 value
) internal override(ERC20, ERC20Votes) {
if (from != address(0) && to != address(0)) {
revert NonTransferrableToken();
}
super._update(from, to, value);
}

Recommendation

Remove the unused inheritance of ERC20Permit from the ZeroVotingERC20 contract.

6.15 Usage of transfer() to move native tokens

FileFix commitTypeStatus
StakingBase.sol2b8c8a5InfoFixed

In order to transfer native tokens, the StakingBase contract currently uses the transfer function. This is not a recommended function to move native tokens due to a fixed gas limit of 2300. As the gas cost is subject to change as part of infra upgrades.

Additionally, when the recipient is a smart contract like a multi-sig wallet, 2300 gas might not cover the gas required to complete the transfer, leading to a revert.

Recommendation

Use a low-level .call function to move native tokens. Additionally, the reentrancy guard should be implemented in different functionalities to prevent reentrancy attacks on both ERC20Staking and ERC721Staking contracts.

6.16 msg.value should be zero when staking ERC20 tokens

FileFix commitTypeStatus
StakingERC20.sol320f333InfoFixed

In the StakingERC20 contract, the staking token can either be an ERC20 token or the native gas token.

For gas tokens, funds are received through the payable operator of the stake(...) function. However, when the staking token is an ERC20 token, the function does not enforce msg.value to be zero. As a result, users may inadvertently send msg.value alongside the ERC20 token amount, potentially leading to the loss of funds.

function _stake(uint256 amount, uint256 lockDuration) internal {
if (amount == 0) {
revert ZeroValue();
}

// @audit msg.value must be 0 when stakingToken !=address(0)
if (config.stakingToken == address(0)) {
if (msg.value != amount) {
revert InsufficientValue();
}
}

Staker storage staker = stakers[msg.sender];

Recommendation

Add a check to ensure msg.value is 0 when config.stakingToken != address(0)

6.17 Errors declared in the interface but never used

FileFix commitTypeStatus
IStakingBase.sol152b9e7Best PracticesFixed

The IStakingBase interface defines the following errors, but they are not used in any of the implementation contracts:

error InvalidStake()
error CannotClaim()

Recommendation

Remove the unused errors declaration.

6.18 Missing zero address check for contract admin

FileFix commitTypeStatus
ZeroVotingERC20.sol80e57e7Best PracticesFixed

The ZeroVotingERC20 contract's constructor takes the admin address as an input. This address is granted the DEFAULT_ADMIN_ROLE, BURNER_ROLE, and MINTER_ROLE roles within the contract. However, the function does not check that this address is not set to address(0).

constructor(
string memory name,
string memory symbol,
address admin
) ERC20(name, symbol) ERC20Permit(name){
//@audit Missing zero address check on admin
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(BURNER_ROLE, admin);
_grantRole(MINTER_ROLE, admin);
}

Recommendation

Implement a check in the constructor, ensuring admin cannot be set to address(0).

6.19 Redundant check for token ownership in _unstakeMany

FileFix commitTypeStatus
StakingERC721.sol5d72252Best PracticesFixed

The _unstakeMany(...) function processes an array of token IDs to be unstaked. For each token, the function checks whether the caller owns it and whether it was previously staked in the contract. This is done through the following checks:

if (
nftStaker.staked[_tokenIds[i]] == false
|| IERC721(config.stakeRepToken).ownerOf(_tokenIds[i]) == address(0) //@audit Unnecessary check
|| IERC721(config.stakeRepToken).ownerOf(_tokenIds[i]) != msg.sender
) {
// Either the list of tokenIds contains a non-existent token
// or it contains a token the owner doesnt own
unchecked {
++i;
}
continue;
}

However, the second check is redundant. Specifically, verifying that the token's owner is not address(0) is unnecessary, as the subsequent check already covers this condition, ensuring the token owner is msg.sender.

Recommendation

Remove the redundant check from the code above.

6.20 Redundant code when overriding the _getContractRewardsBalance function

FileFix commitTypeStatus
StakingERC20.sol06d92d6Best PracticesFixed

The StakingERC20 contract inherits from StakingBase and overrides the _getContractRewardsBalance() function to account for staked tokens when calculating the total rewards in the contract. However, the first part of the function duplicates logic from the base contract’s overridden function.

To eliminate redundancy, this code can be replaced by calling super._getContractRewardsBalance() instead.

function _getContractRewardsBalance() internal view override returns (uint256) {
uint256 balance;
//@audit Following if-else block can be replaced by a call to `super._getContractRewardsBalance()`
if (address(config.rewardsToken) == address(0)) {
balance = address(this).balance;
} else {
balance = IERC20(config.rewardsToken).balanceOf(address(this));
}

if (config.rewardsToken == config.stakingToken) {
return balance - totalStaked;
}

return balance;
}

Recommendation

Replace the redundant code in _getContractRewardsBalance() with a call to super._getContractRewardsBalance().

6.21 Unnecessary stakers mapping in StakingERC721 contract

FileFix commitTypeStatus
StakingBase.sol
StakingERC721.sol
0310dcdBest PracticesFixed

The StakingBase contract implements common functionalities for staking both ERC20 and ERC721 tokens. It includes the stakers mapping, which is used to track each staker's data via the Staker struct.

contract StakingBase is Ownable, ReentrancyGuard, IStakingBase {
...
/**
* @notice Mapping of each staker to that staker's data in the `Staker` struct
*/
mapping(address user => Staker staker) public stakers;
...
}

However, the stakers mapping is only used in the StakingERC20 contract. Since the mapping is declared in the base contract, it becomes part of the storage layout for the StakingERC721 contract as well, which results in redundancy and unnecessary storage usage.

Recommendation

Consider moving the stakers mapping to StakingERC20 interface and implementation 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 Wilderworld 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
223Tests 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.