Halborn Logo

bitsCrunch - NFTStaking - bitsCrunch


Prepared by:

Halborn Logo

HALBORN

Last Updated 04/29/2024

Date of Engagement by: April 2nd, 2024 - April 10th, 2024

Summary

100% of all REPORTED Findings have been addressed

All findings

8

Critical

0

High

0

Medium

0

Low

4

Informational

4


1. Introduction

BitsCrunch engaged Halborn to conduct a security assessment on their smart contracts beginning on 04/04/2024 and ending on 10/04/2024. The security assessment was scoped to the smart contracts provided to the Halborn team.

2. Assessment Summary

The team at Halborn was provided one week for the engagement and assigned a full-time security engineer to evaluate the security of the smart contract.

The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.

The purpose of this assessment is to:

    • Ensure that smart contract functions operate as intended.

    • Identify potential security issues with the smart contracts.


In summary, Halborn identified some minor security risk issues that were mostly addressed by the bitsCrunch team.

3. Test Approach and Methodology

Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy regarding the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance code coverage and quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:

    • Research into architecture and purpose.

    • Smart contract manual code review and walkthrough.

    • Graphing out functionality and contract logic/connectivity/functions. (solgraph)

    • Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.

    • Manual testing by custom scripts.

    • Scanning of solidity files for vulnerabilities, security hot-spots or bugs. (MythX)

    • Static Analysis of security for scoped contract, and imported functions. (Slither)

    • Testnet deployment. (Brownie, Anvil, Foundry)

4. RISK METHODOLOGY

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

4.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILIY METRIC (mem_e)METRIC VALUENUMERICAL VALUE
Attack Origin (AO)Arbitrary (AO:A)
Specific (AO:S)
1
0.2
Attack Cost (AC)Low (AC:L)
Medium (AC:M)
High (AC:H)
1
0.67
0.33
Attack Complexity (AX)Low (AX:L)
Medium (AX:M)
High (AX:H)
1
0.67
0.33
Exploitability EE is calculated using the following formula:

E=meE = \prod m_e

4.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mIm_I)METRIC VALUENUMERICAL VALUE
Confidentiality (C)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Integrity (I)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Availability (A)None (A:N)
Low (A:L)
Medium (A:M)
High (A:H)
Critical (A:C)
0
0.25
0.5
0.75
1
Deposit (D)None (D:N)
Low (D:L)
Medium (D:M)
High (D:H)
Critical (D:C)
0
0.25
0.5
0.75
1
Yield (Y)None (Y:N)
Low (Y:L)
Medium (Y:M)
High (Y:H)
Critical (Y:C)
0
0.25
0.5
0.75
1
Impact II is calculated using the following formula:

I=max(mI)+mImax(mI)4I = max(m_I) + \frac{\sum{m_I} - max(m_I)}{4}

4.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (CC)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (rr)None (R:N)
Partial (R:P)
Full (R:F)
1
0.5
0.25
Scope (ss)Changed (S:C)
Unchanged (S:U)
1.25
1
Severity Coefficient CC is obtained by the following product:

C=rsC = rs

The Vulnerability Severity Score SS is obtained by:

S=min(10,EIC10)S = min(10, EIC * 10)

The score is rounded up to 1 decimal places.
SeverityScore Value Range
Critical9 - 10
High7 - 8.9
Medium4.5 - 6.9
Low2 - 4.4
Informational0 - 1.9

5. SCOPE

Files and Repository
Out-of-Scope:
Remediation Commit ID:
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

0

High

0

Medium

0

Low

4

Informational

4

Security analysisRisk levelRemediation Date
Jump Of Epochs Is PossibleLowSolved - 04/15/2024
Owner Can Permanently Prevent Any User From Retrieving Staked NftLowRisk Accepted
Non Compliance With Approveforall()LowSolved - 04/15/2024
The totalNFTStaked_ Variable Is Never UpdatedLowSolved - 04/15/2024
No Max Limit For NFT Lock PeriodInformationalAcknowledged
Missing Address(0) Check On InitializeInformationalSolved - 04/15/2024
Optimize stakeNFT() FunctionInformationalSolved - 04/15/2024
Misleading Error MessageInformationalAcknowledged

7. Findings & Tech Details

7.1 Jump Of Epochs Is Possible

// Low

Description

Within the EpochManager contract, adjusting the epochLength parameter can result in abrupt transitions between epochs due to the recalibration of lastLengthUpdateEpoch and lastLengthUpdateBlock values. Specifically, changing the epochLength can cause a direct leap in epoch numbers if performed close to the boundary of the current epoch count calculation. This behavior is attributed to the manner in which the new epoch count is derived post-update, relying on the initial lastLengthUpdateBlock as a fixed reference point for all subsequent calculations.

Such sudden transitions in epoch numbers could disrupt the expected progression of epochs, potentially affecting any dependent processes or calculations within the contract or its integrations. The unexpected leap in epoch numbers may not align with the intended or perceived continuity of epochs, especially if the adjustment occurs near the calculation boundaries.

Proof of Concept

The following test case was added to epoch.test.js:

it("Should update correctly but not", async () => {
            const { epochManager } = await loadFixture(deployEpochManager);
            const expectedEpoch = 5
            //E Go to 1 block lower than 5th epoch
            const blocksToMine = EPOCH_LENGTH * (expectedEpoch - 1)
            await mineBlocks(blocksToMine)
            let currentEpoch = await epochManager.currentEpoch()
            //E current epoch = 5 because we started at 1
            expect(currentEpoch).to.be.equal(expectedEpoch);
            let epochLength = await epochManager.epochLength();
            console.log("Current Epoch length : %d",epochLength);
            const latestBlock = (await hre.ethers.provider.getBlock("latest")).number;
            // Mine 
            const newBlocksToMine = 98
            await mineBlocks(newBlocksToMine)
            // should be still be epoch 5
            currentEpoch = await epochManager.currentEpoch()
            console.log("Current Epoch 1 : %d at block %d",currentEpoch,latestBlock+98);
            const newLength = 20;
            await epochManager.setEpochLength(newLength);
            console.log("New Epoch length : %d",newLength);
            currentEpoch = await epochManager.currentEpoch()
            console.log("Current Epoch 2 : %d at block %d",currentEpoch,latestBlock+99);
        });
BVSS
Recommendation

To mitigate this issue, it is advised to check the new epoch length to ensure the update is greater than the currentEpochBlockSinceStart.

Remediation Plan

SOLVED: The bitsCrunch team added a layer of validation on epoch update (cannot be lower than precedent one).

Remediation Hash
References

7.2 Owner Can Permanently Prevent Any User From Retrieving Staked Nft

// Low

Description

The ContributorNFTStaking contract contains a mechanism where the contract owner can indefinitely extend the lock period for staked NFTs through the setNftLockPeriod function. By continuously increasing the nftLockPeriod_, the owner can exploit the check _checkAndRevert(epochPassed >= nftLockPeriod_, "Staking: NFT is Locked"); to ensure this condition always fails within the unstakeNFT function, effectively preventing users from unstaking their NFTs.

This functionality grants the owner undue control over users' assets.

Proof of Concept

The following test case was added to nfftStaking.test.js:

it("Owner prevents users from withdrawing their NFT", async () => {
            const { staking, bitscrunchnft, deployer, contributor1 } = await loadFixture(deployStakingFixture);
            const tokenId = 1
            await mintAndStakeNFT(bitscrunchnft, deployer, staking, contributor1, tokenId)
            const blocksToMine = (EPOCH_MANAGER.EPOCH_LENGTH * (LOCK_PERIOD + 1))
            await mineBlocks(blocksToMine);

            // User should be able to unstake NFT but owner front-run and add period
            let currentLockPeriod = await staking.getParamStates();
            await staking.setNftLockPeriod(currentLockPeriod + 10)
            
            // User try to unstake token but the tx revert
            await expect(staking.connect(contributor1).unstakeNFT())
                .to.be.revertedWithCustomError(staking,"RevertedWithMessage")
                .withArgs("Staking: NFT is Locked");

            // Owner re put normal lock period preventing user from unstaking
            await staking.setNftLockPeriod(currentLockPeriod)
        }); 
BVSS
Recommendation

Introduce an unlockEpoch attribute within the ContributorNft struct to determine the specific epoch when an NFT becomes eligible for unstaking. This attribute is to be set upon staking based on the current epoch plus the current nftLockPeriod_, thus decoupling individual NFT unstake eligibility from subsequent modifications to the global lock period.

Adjustments:

  1. Struct Modification: Add uint256 unlockEpoch; to the ContributorNft struct.

  2. Staking Logic: In stakeNFT, set currentContributorNFT.unlockEpoch to currentEpoch + nftLockPeriod_ upon NFT staking.

  3. Unstaking Logic: In unstakeNFT, replace the global lock period check with a validation against unlockEpoch, allowing unstaking if the current epoch is greater than or equal to unlockEpoch.

Remediation Plan

RISK ACCEPTED: The bitsCrunch team will check if an upper bound for lock period is necessary and update it

References

7.3 Non Compliance With Approveforall()

// Low

Description

The ContributorNFTStaking contract's stakeNFT function currently only checks for individual NFT approval via getApproved and does not account for global approval set through setApprovalForAll. This oversight renders the contract non-compliant with scenarios where users have granted universal approval to manage their NFTs, leading to failed transactions even when setApprovalForAll has been invoked favorably towards the staking contract.

This limitation restricts user flexibility in managing NFT approvals, potentially deterring participation in the staking process due to the inconvenience of having to approve each NFT individually.

Proof of Concept

The following test case was added to nfftStaking.test.js:

async function mintAndStakeNFTWithApproveForAll(nft,deployer,staking,contributor,tokenId) {
        await mintToken(nft, deployer,contributor, tokenId )
        await nft.connect(contributor).setApprovalForAll(staking.address, true)
        await staking.connect(contributor).stakeNFT(tokenId)
    }


it("Reverts if NFT is approvedForAll", async () => {
            const { staking, bitscrunchnft , deployer,contributor1 } = await loadFixture(deployStakingFixture);
            const tokenId = 123
            await expect(mintAndStakeNFTWithApproveForAll(bitscrunchnft,deployer,staking,contributor1,tokenId))
               .to.be.revertedWithCustomError(staking,"RevertedWithMessage")
               .withArgs("Staking: NFT is not approved");
        });

BVSS
Recommendation

Modify the _isNFTApproved function to include a check for global approval via isApprovedForAll, in addition to the existing check for individual approval. The revised function should resemble:

function _isNFTApproved(uint256 _tokenId) internal view returns(bool) {
    return (IERC721Upgradeable(bitscrunchnft_).getApproved(_tokenId) == address(this) || 
            IERC721Upgradeable(bitscrunchnft_).isApprovedForAll(_msgSender(), address(this)));
}

Remediation Plan

SOLVED : The bitsCrunch team Added validation recommended (check either approveForAll or Approved is OK)

Remediation Hash
References

7.4 The totalNFTStaked_ Variable Is Never Updated

// Low

Description

The totalNftStaked_ variable in the ContributorNFTV1Storage contract is never updated within any function, resulting in its value persistently remaining at 0. This oversight implies that the contract fails to accurately track and report the total number of NFTs staked, undermining the integrity of staking data and potentially affecting functionalities reliant on this metric.

BVSS
Recommendation

Implement logic within the stakeNFT and unstakeNFT functions to increment and decrement totalNftStaked_ respectively, upon successful staking or unstaking of an NFT. This adjustment ensures the variable accurately reflects the current total of staked NFTs.

function stakeNFT(uint256 _tokenId) external nonReentrant {
    // .. //
    totalNftStaked_ += 1;
    // .. //
}

function unstakeNFT() external nonReentrant {
    // .. //
    totalNftStaked_ -= 1;
    // .. //
}

Remediation Plan

SOLVED: The bitsCrunch team now update the state in stakeNFT and unstakeNFT functions


Remediation Hash
References

7.5 No Max Limit For NFT Lock Period

// Informational

Description

The ContributorNFTStaking contract currently permits the contract owner to set the NFT lock period (nftLockPeriod_) to any arbitrary value without an upper bound. This design choice grants the owner the capability to indefinitely extend the lock period, potentially restricting users from unstaking their NFTs for an unreasonable duration.

    function setNftLockPeriod(uint256 _lockPeriod) external onlyOwner {
        _checkAndRevert(_lockPeriod > 0, "Staking: Lock Period Cant Be Zero");
        uint256 previousLockPeriod = nftLockPeriod_;
        _setNftLockPeriod(_lockPeriod);
        emit LockPeriodUpdated(_lockPeriod, previousLockPeriod);
    }
BVSS
Recommendation

To mitigate this risk and enhance the contract's robustness, it is recommended to implement a maximum threshold for the NFT lock period. This can be achieved by defining a constant MAX_NFT_LOCK_PERIOD and enforcing this limit within the setNftLockPeriod function. Such a constraint ensures that the lock period remains within a reasonable range, aligning with the contract's intended functionality and user expectations.

// Define a maximum lock period constant
uint256 public constant MAX_NFT_LOCK_PERIOD = <desired_maximum_period>;

function setNftLockPeriod(uint256 _lockPeriod) external onlyOwner {
    require(_lockPeriod > 0 && _lockPeriod <= MAX_NFT_LOCK_PERIOD, "Invalid lock period");
    uint256 previousLockPeriod = nftLockPeriod_;
    nftLockPeriod_ = _lockPeriod;
    emit LockPeriodUpdated(_lockPeriod, previousLockPeriod);
}

Remediation Plan

ACKNOWLEDGED: The bitsCrunch team will internally check if an upper bound for lock period is necessary and update it.

References

7.6 Missing Address(0) Check On Initialize

// Informational

Description

The initialize function in the ContributorNFTStaking contract lacks validation checks for zero-address inputs for critical parameters such as _bitscrunchnft (the NFT contract address) and _epochManager (the Epoch Manager contract address). Even if these addresses are checked during script deployment, this oversight may lead to the contract being initialized with invalid addresses, rendering the staking mechanism non-operational and potentially compromising contract integrity.

Score
Recommendation

Incorporate address validation checks within the initialize function to ensure non-zero addresses are provided for both the NFT contract and the Epoch Manager contract. This can be achieved by adding the following conditions:

require(_bitscrunchnft != address(0), "NFT contract address cannot be zero");
require(_epochManager != address(0), "Epoch Manager address cannot be zero");

Remediation Plan

SOLVED: The bitsCrunch team added address(0) checks.

Remediation Hash
References

7.7 Optimize stakeNFT() Function

// Informational

Description

The stakeNFT and unstakeNFT functions within the ContributorNFTStaking contract exhibit suboptimal gas efficiency due to repeated storage operations performed on the ContributorNft struct. In Solidity, storage operations are costly in terms of gas usage, significantly impacting the overall transaction cost.

In both functions, the currentContributorNFT struct, which is stored in a mapping, is modified multiple times. Each modification directly affects state storage, leading to increased gas consumption.

Score
Recommendation

A more gas-efficient approach involves loading the ContributorNft struct into memory at the function's start, performing all necessary modifications on this memory copy, and then updating the storage mapping only once at the function's end. This method minimizes storage operations, thereby reducing gas costs.

function stakeNFT(uint256 _tokenId) external nonReentrant {
    address staker = _msgSender();
    ContributorNft memory currentContributorNFT = contributorNftInfo_[staker];

    _checkAndRevert(!currentContributorNFT.isStaked, "Staking: Already Staked NFT");
    _checkAndRevert(_isNFTApproved(_tokenId), "Staking: NFT is not approved");
    
    uint256 currentEpoch = epochManager_.currentEpoch();
    currentContributorNFT.nftId = _tokenId;
    currentContributorNFT.isStaked = true;
    currentContributorNFT.lastStakedEpoch = currentEpoch;

    contributorNftInfo_[staker] = currentContributorNFT; // Single storage operation
    TokenUtils.pullNFT(bitscrunchnft_, staker, _tokenId);
    emit StakeNFT(staker, _tokenId, currentEpoch);
}

function unstakeNFT() external nonReentrant {
    address staker = _msgSender();
    ContributorNft memory currentContributorNFT = contributorNftInfo_[staker];

    _checkAndRevert(currentContributorNFT.isStaked, "Staking: Not Staked NFT Yet");
    uint256 epochPassed = epochManager_.epochsSince(currentContributorNFT.lastStakedEpoch);
    _checkAndRevert(epochPassed >= nftLockPeriod_, "Staking: NFT is Locked");

    currentContributorNFT.isStaked = false;
    contributorNftInfo_[staker] = currentContributorNFT; // Single storage operation

    TokenUtils.pushNFT(bitscrunchnft_, staker, currentContributorNFT.nftId);
    emit UnStakeNFT(staker, currentContributorNFT.nftId, currentEpoch);
}

Remediation Plan

SOLVED : The bitsCrunch team will necessary modifications recommended above to be more gas efficient but did not implement it for now

Remediation Hash
References

7.8 Misleading Error Message

// Informational

Description

In the GovernanceController contract, the onlyManager modifier utilizes the error message "Governance:RTO," which stands for "Restricted to Owner." This message is identical to the one used in the onlyOwner modifier, potentially leading to ambiguity since the onlyManager modifier is intended to authorize both managers and the owner. Such uniformity in error messaging could cause confusion regarding the source of access denial, obscuring the distinction between restrictions applied to owners versus managers.

Score
Recommendation

To enhance clarity and improve error message specificity, it is advised to update the error message associated with the onlyManager modifier. A distinct error message will better reflect the broader access control intended by this modifier, which encompasses both managers and the owner.

modifier onlyManager() {
    _checkAndRevert(
        hasRole(MANAGER_ROLE, _msgSender()) || hasRole(DEFAULT_ADMIN_ROLE, _msgSender()),
        "Governance:RTMO" // "Restricted to Manager/Owner"
    );
    _;
}

Remediation Plan

ACKNOWLEDGED: The bitsCrunch team did not update the code now as it is used in multiple other deployed contracts

References

8. Automated Testing

Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework.

After Halborn verified the smart contracts in the repository and was able to compile them correctly into their abis and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.


Slither 1Slither 2Slither 3

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.

© Halborn 2024. All rights reserved.