Download audit report
This document presents the results of a security review conducted by Nethermind Security for idOS Node Staking contracts.
The IDOS Protocol is a decentralized staking framework built on Arbitrum One. The system enables token holders to participate in network security by delegating their IDOSToken assets to verified node operators. In return for securing the network, participants earn staking rewards distributed through an epoch-based algorithmic model, with each epoch lasting 1 day. The protocol is modular and consists of a core staking contract, a custom ERC20 token implementation, and a vesting utility for linear token distribution. The system also implements a slashing mechanism that the off-chain governance can use in order to punish nodes that misbehave.
The audit comprises 330 lines of the Solidity code. The audit was performed using (a) manual analysis of the codebase, and (b) automated analysis tools.
Findings by severity
| # | Contract | LoC | Comments | Ratio | Blank | Total |
| 1 | IDOSVesting.sol | 24 | 9 | 37.5% | 4 | 37 |
| 2 | IDOSNodeStaking.sol | 282 | 7 | 2.5% | 72 | 361 |
| 3 | IDOSToken.sol | 24 | 2 | 8.3% | 6 | 32 |
| Total | 330 | 18 | 5.5% | 82 | 430 |
The IDOS Protocol is a decentralized staking framework built on Arbitrum One. The system enables token holders to participate in network security by delegating their IDOSToken assets to verified node operators. In return for securing the network, participants earn staking rewards distributed through an epoch-based algorithmic model. The protocol is modular, consisting of a core staking contract, a custom ERC20 token implementation, and a vesting utility for linear token distribution.
The entry point for user participation is the IDOSNodeStaking contract. To participate, users must possess IDOSToken assets and delegate them to a specific node operator.
stake(...) function, specifying the amount of tokens and the target node address.IDOSNodeStaking contract, where they are held in custody until the user initiates an unstaking procedure.The protocol utilizes a discrete time model divided into epochs, with each epoch defined by the constant EPOCH_LENGTH (set to 1 day). Rewards are calculated based on the state of the system and the reward rate active during each specific epoch.
rewardAcc, userStakeAcc) to track the total rewards per staked token over time.createEpochCheckpoint(...) function. This function synchronizes the user's local state with the global accumulators, iterating through all epochs that have elapsed since the user's last interaction.setEpochReward are persistent; a new reward value applies to the current epoch and remains active for all subsequent epochs until explicitly changed. During calculation, the system applies the specific reward rate that was valid during each iterated epoch.To ensure network stability and prevent capital flight, the protocol enforces a mandatory unbonding period for all staked assets.
unstake(...) to request the withdrawal of their assets. This action moves the tokens from the “Active Stake” state to a “Pending Withdrawal” state. The protocol records the timestamp of this request.UNSTAKE_DELAY is configured to 14 days. During this window, the assets are locked and do not earn rewards.withdrawUnstaked(). This function iterates through the user's unstake requests, aggregates all mature requests, and transfers the principal IDOSToken amount back to the user's wallet.withdrawReward() function without affecting the principal stake.The IDOS Protocol relies on a permissioned set of node operators to maintain network integrity. The management of these nodes is handled by the protocol owner.
slash(...) function. This action immediately freezes the node, preventing further stakes.withdrawSlashedStakes(), allowing for off-chain redistribution or burning depending on governance decisions.To support long-term alignment of incentives, the protocol includes an IDOSVesting contract. This contract extends the standard OpenZeppelin VestingWallet with “Cliff” functionality. It allows the protocol to allocate tokens to beneficiaries (such as team members, early investors, or the protocol treasury) that unlock linearly over a specified duration, but only after a defined cliffSeconds threshold has passed.
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.
stake function doesn't check if the node is allowListed| File | Fix commit | Type | Status |
contracts/IDOSNodeStaking.sol | 4f8d87f | Critical | Fixed |
The IDOSNodeStaking.stake(...) function doesn't check if the input parameter node is part of the allowlistedNodes set.
function stake(address user, address node, uint256 amount) external nonReentrant whenNotPaused {
//@audit--issue lack of checks that the node is actually allowlisted
require(node != address(0), ZeroAddressNode());
require(!slashedNodes.contains(node), NodeIsSlashed(node));
require(amount > 0, AmountNotPositive(amount));
require(block.timestamp >= startTime, NotStarted());
if (user == address(0)) user = msg.sender;
//..
//..
}This allows users to pick any arbitrary address as the node to stake with. A user can even pick their own address as the node. This would allow a node to be both a staker and node at the same time. The problem is that these stakers will accrue rewards, just like the ones who staked on the allowlistedNodes would and this enables malicious users to steal the staking rewards from honest stakers and nodes.
As a side note, the unstake(...) and slash(...) functions don't implement this check either.
Recommendation
Consider adding a check that the node input parameter is part of the allowlistedNodes list.
withdrawableReward function uses incorrect reward values for epochs| File | Fix commit | Type | Status |
contracts/IDOSNodeStaking.sol | PR #3 | High | Fixed |
The withdrawableReward(...) function in the IDOSNodeStaking contract calculates the pending rewards for a user by iterating through the epochs that have passed since the user's last checkpoint. The protocol starts from a default epoch reward value (stored at key 0) and allows the owner to change the epoch reward via setEpochReward. Each call to setEpochReward at epoch X is intended to define a new reward value that applies from epoch X onward until changed again (i.e. rewards are _sticky_ across epochs).
The issue lies in how the epochReward variable is initialized relative to the user's checkpoint. At the beginning of withdrawableReward(...), the epochReward variable is always set to the default reward at index 0, regardless of any changes that may have occurred before the user's last checkpoint. The function then iterates from checkpoint.epoch up to currentEpoch(), and only updates epochReward if there is an entry in epochRewardChanges at the _exact_ epoch i:
function withdrawableReward(address user)
public
view
returns (uint256 withdrawableAmount, uint256 rewardAcc, uint256 userStakeAcc, uint256 totalStakeAcc)
{
// ...
//@audit rewards are set to the default value
uint256 epochReward = epochRewardChanges.get(0);
for (uint48 i = checkpoint.epoch; i < currentEpoch(); i++) {
//@audit if rewards were not updated exactly at epoch `i` the protocol
//uses the default value, which may be incorrect
(bool exists, uint256 rewardAtEpoch) = epochRewardChanges.tryGet(i);
if (exists) epochReward = rewardAtEpoch;
// ...
if (totalStakeAcc == 0) continue;
rewardAcc += (userStakeAcc * epochReward) / totalStakeAcc;
}
//..
}Whenever the epoch reward has been changed at some epoch X and a user’s checkpoint is at a later epoch checkpoint epoch > X, the rewards for epochs >= user's checkpoint are computed using the default reward instead of the last changed reward. This will lead to incorrect rewards calculations for stakers.
Recommendation
Consider revisiting the loop logic to ensure correct rewards calculations.
withdrawUnstaked(...) results in potential Denial of Service| File | Fix commit | Type | Status |
contracts/IDOSNodeStaking.sol | — | Low | Acknowledged |
The unstake(...) function allows users to initiate the withdrawal process by appending an Unstake struct to the unstakesByUser array. After the UNSTAKE_DELAY period, users call the withdrawUnstaked(...) function to claim their tokens.
However, the withdrawUnstaked(...) function iterates over the entire unstakesByUser array in a single transaction to find eligible withdrawals.
function withdrawUnstaked()
external nonReentrant whenNotPaused
returns (uint256 withdrawableAmount)
{
withdrawableAmount;
// @audit The loop iterates over the entire array which can grow indefinitely
for (uint i; i < unstakesByUser[msg.sender].length; i++)
if (unstakesByUser[msg.sender][i].timestamp < uint48(block.timestamp) - UNSTAKE_DELAY) {
withdrawableAmount += unstakesByUser[msg.sender][i].amount;
// @audit-issue 'delete' zeroes the element but does not reduce array length
delete unstakesByUser[msg.sender][i];
}
// ...
}This logic presents two significant risks that can cause the transaction to exceed the block gas limit, leading to a Denial of Service (DoS) where funds become permanently locked:
delete to clear processed unstakes. In Solidity, delete only resets the value to default (zero) but does not remove the index or decrease the array length. Consequently, the array grows indefinitely, forcing the loop to process an increasing number of "ghost" entries from past withdrawals;withdrawUnstaked(...) for a long period, the array will contain a large number of valid, pending entries. Attempting to process all of them in a single loop may consume more gas than the block limit allows;Recommendation
Consider refactoring the withdrawal logic to prevent unbounded iteration and support batch processing. Two potential solutions include:
delete operation with a "swap and pop" mechanism. This involves moving the last element of the array to the position of the withdrawn element and then popping the array to decrease its length. This should also be combined with a user-provided parameter to limit the number of withdrawals processed per transaction;Resolution
We ran the following test: after 700 noisy epochs (with 100 other users staking, unstaking, etc), we had a user stake every day and unstake every other day for another 700 quiet epochs; last unstake cost 200k gas.
withdrawableReward(...) leads to Denial of Service for new and inactive stakers| File | Fix commit | Type | Status |
contracts/IDOSNodeStaking.sol | — | Low | Acknowledged |
The stake(...), unstake(...) and withdrawReward(...) functions utilize the createEpochCheckpoint(...) function to update the user's state. This internal logic relies on withdrawableReward(...) to calculate the pending rewards accumulated since the user's last interaction.
However, the withdrawableReward(...) function calculates rewards by iterating from the user's last recorded epoch ( checkpoint.epoch) up to the currentEpoch(). For a new user who has never interacted with the contract, the default value of checkpoint.epoch is 0.
function withdrawableReward(address user)
public view
returns (uint256 withdrawableAmount, uint256 rewardAcc, uint256 userStakeAcc, uint256 totalStakeAcc)
{
EpochCheckpoint memory checkpoint = epochCheckpointByUser[user];
// ...
// @audit If the user is new, checkpoint.epoch is 0.
// @audit If the protocol has been running for a long time, this loop becomes extremely large.
for (uint48 i = checkpoint.epoch; i < currentEpoch(); i++) {
// ...
}
// ...
}This creates a Denial of Service (DoS) vector in two scenarios:
stake(...) will trigger the loop from epoch 0 to the current epoch. If the gas cost of this iteration exceeds the block gas limit, the user effectively cannot stake;unstake(...) or claim rewards;Recommendation
Consider implementing the following changes to mitigate the DoS risk:
checkpoint.epoch directly to currentEpoch() to avoid iterating through history where they had no stake;1 (or higher) by adjusting the startTime in the constructor;Note regarding economic viability: While pagination solves the hard DoS limits, if the gas cost to process a single epoch iteration exceeds the rewards earned in that epoch, the function becomes economically unviable to execute. However, given that the contract is deployed on Arbitrum One which is expected to have low gas fees, this specific economic edge case is considered unlikely to happen.
Resolution
If a user stakes after 1400 epochs, the transaction still has enough gas to go through. Furthermore, the contract will not be used for more than a couple of months before being replaced, so it will be replaced before this issue can occur.
slash(...) transaction| File | Fix commit | Type | Status |
contracts/IDOSNodeStaking.sol | — | Low | Acknowledged |
The IDOSNodeStaking contract allows users to delegate their tokens to nodes via the stake(...) function. If a node acts maliciously, the contract owner can call the slash(...) function to penalize the node. This action is intended to freeze the stakes associated with that node, preventing withdrawals. Users can normally retrieve their funds by calling unstake(...), waiting for the UNSTAKE_DELAY, and then calling withdrawUnstaked(...).
The vulnerability lies in the fact that unstake(...) immediately reduces the staked balance associated with the node in the stakeByNode and stakeByNodeByUser mappings. If a user anticipates that a node is about to be slashed (e.g., through off-chain monitoring or governance announcements), they can call unstake(...) before the slash(...) transaction is executed.
Once the user calls unstake(...), the funds are moved to a pending state in the unstakesByUser array, effectively dissociating them from the node. When slash(...) is subsequently executed, it only locks the funds currently staked on the node. The withdrawUnstaked(...) function does not verify if the node associated with the pending withdrawal was slashed. Consequently, the user can withdraw their funds after the delay, successfully bypassing the slashing penalty.
Although the contract is deployed on Arbitrum One, which mitigates standard mempool front-running, this issue remains relevant if the slashing decision is predictable or communicated off-chain prior to on-chain execution.
function unstake(address node, uint256 amount)
external nonReentrant whenNotPaused
{
// ...
// @audit Checks if node is currently slashed, but doesn't prevent future slashing evasion
require(!slashedNodes.contains(node), NodeIsSlashed(node));
// ...
// @audit-issue User stake is immediately decremented from the node mapping
stakeByNodeByUser[msg.sender][node] -= amount;
// ...
// @audit-issue Node total stake is immediately reduced
uint256 newNodeStake = getNodeStake(node) - amount;
if (newNodeStake > 0) {
stakeByNode.set(node, newNodeStake);
} else {
stakeByNode.remove(node);
}
// ...
// @audit The pending unstake does not track which node these funds came from
unstakesByUser[msg.sender].push(Unstake(amount, uint48(block.timestamp)));
// ...
}Recommendation
Consider updating the Unstake struct to store the address of the node being unstaked. In the withdrawUnstaked(...) function, introduce logic to verify if the corresponding node has been slashed.
To ensure fairness, distinguish between long-standing unstake requests and those made immediately prior to a slashing event. Consider checking if the node was slashed within a specific window relative to the unstake timestamp. If the slash happened shortly after the unstake, the withdrawal should be blocked or penalized.
Crucially, if a user's withdrawal is blocked due to slashing, ensure the system correctly accounts for these funds so they are recoverable by the protocol. Since unstake(...) reduces the stakeByNode mapping, these funds would not normally be captured by the withdrawSlashedStakes(...) function (which iterates over current node stakes). The logic must essentially revert the accounting reductions made during unstake(...) or explicitly route these blocked funds to the slashed pool to ensure they can be withdrawn by the owner.
Resolution
This applies only to “off-chain front-running” since Arbitrum One has no public mempool. And validators won’t risk slashing by announcing slashable offenses publicly. We don’t really expect slashing with the small and trusted validator set we’ll have for the foreseeable future (note again this contract is unlikely to live for more than a few months).
| File | Fix commit | Type | Status |
contracts/IDOSNodeStaking.sol | — | Low | Acknowledged |
The IDOSNodeStaking contract incentivizes users to stake tokens delegated to nodes by distributing rewards over fixed time periods called epochs. The duration of an epoch is defined by EPOCH_LENGTH, which is set to 1 day. Users can stake tokens using the stake(...) function and claim their accumulated rewards via withdrawReward(...).
The issue arises from the way rewards are calculated relative to the time of staking. When a user calls stake(...), their stake is recorded for the current epoch. Subsequently, the withdrawableReward(...) function calculates rewards by iterating through completed epochs. It uses the total stake recorded for a specific epoch to determine the user's share of the rewards. However, the calculation does not account for the duration the tokens were actually staked within that epoch.
This allows a malicious user to deposit a large amount of tokens just before the epoch ends (e.g., in the last second). As soon as the next epoch begins, the protocol considers their stake to have been active for the entire duration of the previous epoch. Consequently, the attacker receives the full reward for that epoch despite their funds being exposed to the protocol (and potential slashing risks) for only a brief moment. This behavior dilutes the rewards of honest stakers who risk their capital for the full duration.
The stake(...) function updates the mapping for the current epoch immediately:
function stake(address user, address node, uint256 amount)
external nonReentrant whenNotPaused
{
// ...
stakeByNodeByUser[user][node] += amount;
// @audit The stake is added to the current epoch's balance immediately
stakeByUserByEpoch[currentEpoch()][user] += amount;
stakedByEpoch[currentEpoch()] += amount;
// ...
}The withdrawableReward(...) function calculates the reward based on that balance, treating it as valid for the whole epoch:
function withdrawableReward(address user)
public view
returns (uint256 withdrawableAmount, uint256 rewardAcc, uint256 userStakeAcc, uint256 totalStakeAcc)
{
// ...
for (uint48 i = checkpoint.epoch; i < currentEpoch(); i++) {
// ...
// @audit The user's stake is added to the accumulator for the entire epoch `i`
userStakeAcc += stakeByUserByEpoch[i][user];
userStakeAcc -= unstakeByUserByEpoch[i][user];
totalStakeAcc += stakedByEpoch[i];
// ...
// @audit Rewards are distributed based on the final stake of the epoch
rewardAcc += (userStakeAcc * epochReward) / totalStakeAcc;
}
// ...
}Recommendation
Consider modifying the staking logic so that deposits made during the current epoch only become eligible for rewards starting from the subsequent epoch. This ensures that a user's funds must be staked for at least one full epoch cycle before earning rewards, aligning the reward incentive with the risk exposure.
Resolution
This was a deliberate trade-off. We eschewed the complexity of implementing a more complex AMM/staking pattern (e.g. Synthetix, Uniswap) by using short 1-day epochs combined with a 14-day unbonding period.
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 idOS team provided a walkthrough of the in-scope contracts during the kickoff call. The team addressed the questions and concerns raised by the Nethermind Security team, providing valuable insights and a comprehensive understanding of the project's technical aspects.
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.
| 48Tests 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