Halborn Logo

Lighthouse NFT Staking - Seascape


Prepared by:

Halborn Logo

HALBORN

Last Updated 04/26/2024

Date of Engagement by: April 19th, 2022 - April 29th, 2022

Summary

88% of all REPORTED Findings have been addressed

All findings

8

Critical

2

High

0

Medium

1

Low

1

Informational

4


1. INTRODUCTION

\client engaged Halborn to conduct a security audit on their smart contracts beginning on 2022-04-19 and ending on April 29th, 2022. The security assessment was scoped to the smart contracts provided to the Halborn team.

The smart contracts allowed for staking and unstaking NFTs on the Lighthouse platform, as well as claiming the rewards.

2. AUDIT SUMMARY

The team at Halborn was provided one week for the engagement and assigned a full-time security engineer to audit 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 audit is to:

    • Ensure that smart contract functions operate as intended

    • Identify potential security issues with the smart contracts

In summary, Halborn identified some security risks that should the contracts be deployed as they are, users would risk losing their staked assets. These issues stemmed from simple code mistakes, which prevent users who staked an asset from unstaking it and claiming their rewards. Additionally, due to an inverted if statement on the imported code that the smart contracts rely on, when unstaking an asset, they will always be burned.

Halborn recommends that the code is reviewed by the Seascape team and that the required corrections are implemented. Furthermore, it is recommended that thorough test cases are implemented to identify all possible errors with the code and extreme cases that users might encounter.

In summary, Seascape fixed the majority of the issues described in the report.

3. TEST APPROACH & METHODOLOGY

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

    • 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

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

    • Testnet deployment (Brownie, Remix IDE)

4. SCOPE

The assessment was scoped to the LightHouseStake.sol and StakeNftInterface.sol contracts available at \href{https://github.com/Seastarinteractive/lighthouse-smartcontracts/tree/nft-staking/contracts}{GitHub}.

The commit ID at the time of the assessment was the following: 54fdb88df32b974370f10d2e50b8f9a80a6bdb47

The commit ID of the fixes were the following:

    • d4c66554ddedffd06e10927f4aab05a35e08368f - \href{https://github.com/Seastarinteractive/lighthouse-smartcontracts}{LighthouseStake}

    • 99c803ffff80aae53405f8460f3373ee8a2386d2 - \href{https://github.com/Seastarinteractive/lighthouse-smartcontracts}{LighthouseStake}

    • faa1bd6609fd6443e4e4d7d082166012176b9ad1 - \href{https://github.com/blocklords/seascape-smartcontracts/tree/main/contracts/defi}{Seascape Smart Contracts}

5. RISK METHODOLOGY

Vulnerabilities or issues observed by Halborn are ranked based on the risk assessment methodology by measuring the LIKELIHOOD of a security incident and the IMPACT should an incident occur. This framework works for communicating the characteristics and impacts of technology vulnerabilities. The quantitative model ensures repeatable and accurate measurement while enabling users to see the underlying vulnerability characteristics that were used to generate the Risk scores. For every vulnerability, a risk level will be calculated on a scale of 5 to 1 with 5 being the highest likelihood or impact.
RISK SCALE - LIKELIHOOD
  • 5 - Almost certain an incident will occur.
  • 4 - High probability of an incident occurring.
  • 3 - Potential of a security incident in the long term.
  • 2 - Low probability of an incident occurring.
  • 1 - Very unlikely issue will cause an incident.
RISK SCALE - IMPACT
  • 5 - May cause devastating and unrecoverable impact or loss.
  • 4 - May cause a significant level of impact or loss.
  • 3 - May cause a partial impact or loss to many.
  • 2 - May cause temporary impact or loss.
  • 1 - May cause minimal or un-noticeable impact.
The risk level is then calculated using a sum of these two values, creating a value of 10 to 1 with 10 being the highest level of security risk.
Critical
High
Medium
Low
Informational
  • 10 - CRITICAL
  • 9 - 8 - HIGH
  • 7 - 6 - MEDIUM
  • 5 - 4 - LOW
  • 3 - 1 - VERY LOW AND INFORMATIONAL

6. SCOPE

Out-of-Scope: New features/implementations after the remediation commit IDs.

7. Assessment Summary & Findings Overview

Critical

2

High

0

Medium

1

Low

1

Informational

4

Impact x Likelihood

HAL-01

HAL-02

HAL-04

HAL-03

HAL-05

HAL-06

HAL-07

HAL-08

Security analysisRisk levelRemediation Date
NFT OWNERS CANNOT UNSTAKE OR CLAIM REWARDSCriticalSolved - 05/09/2022
NFT WILL ALWAYS BE BURNED WHEN UNSTAKINGCriticalSolved - 05/19/2022
NEW STAKING SESSIONS CANNOT BE CREATEDMediumSolved - 05/09/2022
MISSING REENTRANCY GUARDLowSolved - 05/09/2022
UNCLEAR ERROR HANDLINGInformationalSolved - 05/09/2022
INSUFFICIENT TESTSInformationalAcknowledged
MISLEADING COMMENTSInformationalSolved - 05/09/2022
UNUSED VARIABLEInformational-

8. Findings & Tech Details

8.1 NFT OWNERS CANNOT UNSTAKE OR CLAIM REWARDS

// Critical

Description

After staking, users are neither able to withdraw their NFT nor claim the rewards due to a mistake in the code. This will lead to the asset being locked forever.

LightHouseStaking.sol Lines# 157 and 177.

 function unstake(uint256 sessionId, uint256 nftId) external {
        address staker = msg.sender;
        Session storage session = sessions[sessionId];
        require(session.rewardPool > 0, "session does not exist");

        Player storage playerChallenge = playerParams[sessionId][nftId];
        require(playerChallenge.player != msg.sender, "forbidden");

        StakeNft handler = StakeNft(stakeHandler);
        handler.claim(sessionId, staker);

        handler.unstake(sessionId, staker, nftId, false);
        delete playerParams[sessionId][nftId];

        emit Unstake(staker, sessionId, nftId);
    }

    /// @notice CLAIMING:
    /// you can't call this function is time is completed.
    /// you can't call this function if nft is burning.
    function claim(uint256 sessionId, uint256 nftId) external {
        Session storage session = sessions[sessionId];
        address staker = msg.sender;
        Player storage playerChallenge = playerParams[sessionId][nftId];

        require(session.rewardPool > 0, "session does not exist");
        require(playerChallenge.player != msg.sender, "forbidden");

        StakeNft handler = StakeNft(stakeHandler);
        uint256 claimed = handler.claim(sessionId, staker);

        emit Claim(staker, sessionId, nftId, claimed);
    }
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: The Seascape team amended the smart contract as suggested.

\newpage

8.2 NFT WILL ALWAYS BE BURNED WHEN UNSTAKING

// Critical

Description

Testing revealed that NFTs will always be burned when unstaked due to an error in the StakeNft.sol contract, which burns the token when the variable burn is set to false. Users might not be aware of this when staking their NFT, thus resulting in the loss of their asset.

Code Location

LightHouseStake.sol Lines #151-166

 function unstake(uint256 sessionId, uint256 nftId) external {
        address staker = msg.sender;
        Session storage session = sessions[sessionId];
        require(session.rewardPool > 0, "session does not exist");

        Player storage playerChallenge = playerParams[sessionId][nftId];
        require(playerChallenge.player != msg.sender, "forbidden");

        StakeNft handler = StakeNft(stakeHandler);
        handler.claim(sessionId, staker);

        handler.unstake(sessionId, staker, nftId, false);
        delete playerParams[sessionId][nftId];

        emit Unstake(staker, sessionId, nftId);
    }

StakeNft.sol Lines #79-99

 function unstake(uint key, address stakerAddr, uint id, bool burn)
        external
        nonReentrant
    {
        address stakeToken = periods[msg.sender][key].stakeToken;
        IERC721 nft = IERC721(stakeToken);

        require(nft.ownerOf(id) == address(this), "not owned");
        require(owners[msg.sender][key][id] == stakerAddr);

        delete owners[msg.sender][key][id];
        delete weights[msg.sender][key][id];

        if (burn) {
            nft.safeTransferFrom(address(this), stakerAddr, id);
        } else {
            nft.safeTransferFrom(address(this), 0x000000000000000000000000000000000000dEaD, id);
        }

        withdraw(key, stakerAddr, weights[msg.sender][key][id]);
    }
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: The Seascape team amended the contracts to remove the burning functionality. Every time a user unstakes their ERC721, it will not be burned, but transferred to them.

\newpage

8.3 NEW STAKING SESSIONS CANNOT BE CREATED

// Medium

Description

Within the addSession function of LighthouseStake.sol, the startTime of the session needs to be after the current timestamp, thus in the future. However, this function calls the newPriod function of StakeNft.sol which subsequently calls the newStakePeriod of Stake.sol. Within this last smart contract's function, a check is enforced that the startTime is before or equal to the current timestamp, thus in the past. This is contradictory as the functions are called one after the other, and this will result in the session not being created.

Code Location

LighthouseStake.sol Lines #67-104

[snip]
require(
    rewardPool > 0 && period > 0 && startTime > block.timestamp,
    "zero_value"
);
[snip]

stakeNft.sol Lines #33-56

[snip]
newStakePeriod(key, startTime, endTime, rewardPool);
[snip]

\newpage

Stake.sol Lines #45-51 and #72-91

modifier validStakePeriodParams(uint key, uint startTime, uint endTime, uint rewardPool) {
    require(startTime <= block.timestamp,                   "STAKE_TOKEN: invalid_start");
    require(startTime < endTime,                            "STAKE_TOKEN: invalid_time");
    require(rewardPool > 0,                                 "STAKE_TOKEN: zero_value");
    require(stakePeriods[msg.sender][key].startTime == 0,   "STAKE_TOKEN: period_exists");
    _;
}

function newStakePeriod(
    uint key,               // a unique identifier. could be a session id.
    uint startTime,
    uint endTime,
    uint rewardPool         // if usdc, then decimals is 9 for reward pool.
                            // if cws, then decimals is 18 for reward pool.
)
    internal 
    validStakePeriodParams(key, startTime, endTime, rewardPool)
{
    // Challenge.stake is not null, means that earn is not null too.
    StakePeriod storage period  = stakePeriods[msg.sender][key];
    period.rewardPool           = rewardPool;
    period.startTime            = startTime;
    period.endTime              = endTime;
    period.unit                 = rewardPool * SCALER / (endTime - startTime);
    period.rewardClaimableTime  = startTime;

    emit NewStakePeriod(msg.sender, key, startTime, endTime);
}
Score
Impact: 2
Likelihood: 5
Recommendation

SOLVED: The imported contract was amended to ensure that the startTime is in the future.

\newpage

8.4 MISSING REENTRANCY GUARD

// Low

Description

During the audit, it was discovered that all the public functions within the smart contract LighthouseStake.sol such as stake, unstake and claim did not implement protections against reentrancy attacks. To protect against reentrancy attacks, it may be necessary to use a mutex. By using this lock, an attacker can no longer exploit the withdrawal function with a recursive call. OpenZeppelin has its own mutex implementation called ReentrancyGuard which provides a modifier to any function called nonReentrant that protects the function against re-entrancy attacks.

Code Location

The affected functions were in the file LighthouseStake.sol

  • stake
  • unstake
  • claim
Score
Impact: 2
Likelihood: 2
Recommendation

SOLVED: The nonReentrant modifier was added to highlighted functions.

\newpage

8.5 UNCLEAR ERROR HANDLING

// Informational

Description

Within the addSession function of the LighthouseStake contract, one require statement does not produce the appropriate error message. Three different conditions are checked in this statement, and the error message only effectively describes two of them.

Code Location

LighthouseStake.sol - Lines #80-83

require(
    rewardPool > 0 && period > 0 && startTime > block.timestamp,
    "zero_value"
);
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Seascape team amended the code as suggested.

\newpage

8.6 INSUFFICIENT TESTS

// Informational

Description

It was identified that the tests included within the repository were not sufficient. Should Seascape have implemented more thorough unit tests, certain issues highlighted within this report would have been identified.

Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Seascape team did not implement more extensive test cases for NFT staking contracts.

\newpage

8.7 MISLEADING COMMENTS

// Informational

Description

Within the LigthouseStake contract, the stake function is described as taking a signature and the amount of assets staked. However, the function definition does only accept the sessionId and nftId parameters. Furthermore, the comment description of the unstake and claim functions do not reflect the parameters passed to the function and their logic.

Should the code called by these functions require these parameters, they would not be provided, and the code might not work as expected. Furthermore, this reduces readability of the code and could introduce errors in code that depend on this smart contract.

LighthouseStake.sol Lines #106-146

/// @notice Stake an  and some token.
/// For the first time whe user deposits his :
///     It receives  id, signature and amount of staking.
function stake(uint256 sessionId, uint256 nftId) external 
[snip]

LighthouseStake.sol Lines #148-166

/// @notice Unstake nft. If the challenge is burning in this sesion
/// then burn nft.
/// @dev data variable is not used, but it is here for following the ZombieFarm architecture.
function unstake(uint256 sessionId, uint256 nftId) external 
[snip]
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Seascape teamamended the comment to reflect the functionality of highlighted functions.

\newpage

8.8 UNUSED VARIABLE

// Informational

Description
Finding description placeholder
Score
Impact:
Likelihood:

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.