Halborn Logo

Token Staking Vault - BSX


Prepared by:

Halborn Logo

HALBORN

Last Updated 11/25/2024

Date of Engagement by: November 21st, 2024 - November 22nd, 2024

Summary

100% of all REPORTED Findings have been addressed

All findings

12

Critical

0

High

0

Medium

0

Low

1

Informational

11


1. Introduction

BSX engaged Halborn to conduct a security assessment on their Staking Vault smart contracts beginning on November 21st, 2024 and ending on November 22nd, 2024. The security assessment was scoped to the BSX Staking Vault smart contracts in the GitHub repository provided to the Halborn team. Commit hashes and further details can be found in the Scope section of this report.

2. Assessment Summary

Halborn was provided two days for the engagement and assigned one full-time security engineer to review the security of the smart contract in scope. The engineer is a blockchain and smart contract security expert with advanced penetration testing and smart contract hacking skills, and deep knowledge of multiple blockchain protocols.

The purpose of the assessment is to:

    • Identify potential security issues within the smart contracts.

    • Ensure that smart contract functionality operates as intended.


In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were mostly addressed by the BSX team. The main identified issues were the following:

    • Mitigate incompatibilities with fee-on-transfer tokens by verifying transfers or documenting the limitation.

    • Address staking and unstaking inconsistencies to prevent stuck funds.

    • Enhance input validation across critical functions.

    • Ensure consistent use of OpenZeppelin's SafeERC20 library.

    • Optimize gas usage.

    • Refactor and simplify code for readability and maintainability.


3. Test Approach and Methodology

Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the contracts' solidity 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 assessment:

    • Research into architecture and purpose.

    • Smart contract manual code review and walk-through.

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

    • Local testing with custom scripts (Foundry).

    • Fork testing against main networks (Foundry).

    • Static analysis of security for scoped contract, and imported functions

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
(a) Repository: bsx-token
(b) Assessed Commit ID: 0b68ca0
(c) Items in scope:
  • src/StakingVault.sol
  • src/interfaces/IStakingVault.sol
  • src/BsxToken.sol
Out-of-Scope: Third party dependencies and economic attacks.
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

1

Informational

11

Security analysisRisk levelRemediation Date
Incompatibilities With fee-on-transfer TokensLowSolved - 11/25/2024
Potential for Stuck Funds in Staking ProcessInformationalPartially Solved - 11/25/2024
Missing Input ValidationInformationalSolved - 11/25/2024
Unsafe ERC20 OperationsInformationalSolved - 11/25/2024
Owner Can Renounce OwnershipInformationalAcknowledged - 11/25/2024
Cache Array Length Outside of LoopInformationalSolved - 11/25/2024
Unlocked Pragma CompilerInformationalAcknowledged - 11/25/2024
Consider Using Named MappingsInformationalSolved - 11/25/2024
Unused ImportInformationalSolved - 11/25/2024
Use Calldata For Function Arguments Not MutatedInformationalSolved - 11/25/2024
Style Guide OptimizationsInformationalSolved - 11/25/2024
Redundant InheritanceInformationalAcknowledged - 11/25/2024

7. Findings & Tech Details

7.1 Incompatibilities With fee-on-transfer Tokens

// Low

Description

The current implementation of the stake() and unstake() functions assumes that the full amount specified by the user is transferred without any deductions. This assumption is incompatible with fee-on-transfer tokens, which deduct a fee during the transfer process. Specifically:

  • In stake(), the contract mints staking tokens based on the amount parameter, even though the actual tokens received may be less.

  • In unstake(), the contract transfers the full amount back to the user, potentially exceeding the actual balance held due to fee deductions.


These behaviors can lead to inconsistencies in the accounting and operation of the staking contract, particularly if a fee-on-transfer token is accidentally or intentionally used.


The risk of this finding was reduced from medium to low because the stakingToken is meant to be only BSX, which is not a fee-on-transfer token. However, this could become an important risk if the contract is repurposed or reused with other tokens in the future.

BVSS
Recommendation

Consider any of the following mitigations:

  • Implement a mechanism to account for fee-on-transfer behavior by comparing the actual received or transferred token amount with the specified amount. For example:

    • In stake(), calculate the actual tokens received using balanceOf before and after the transferFrom call.

    • In unstake(), update the balance logic to ensure that the full unstake amount can be safely transferred, accounting for any potential transfer fees.

  • Document the contract’s incompatibility with fee-on-transfer tokens to warn users and developers against unintended misuse.

Remediation

SOLVED: The BSX team fixed this finding in commit 63d57be by adding a NatSpec comment in the contract indicating that the contract does not support fee-on-transfer tokens.

Remediation Hash

7.2 Potential for Stuck Funds in Staking Process

// Informational

Description

The stake() function allows users to stake tokens on behalf of another address by passing the account argument. However, unstaking via requestUnstake(), cancelUnstakeRequests(), or unstake() requires the caller (msg.sender) to match the staked address, as these functions do not accept an account argument. This inconsistency could result in funds becoming permanently stuck if tokens are staked to an address without a private key or control.


See their function signatures:

function stake(address account, uint256 amount) external;
function requestUnstake(uint256 requestId, uint256 amount) external;
function cancelUnstakeRequests(uint256[] memory requestIds) external;
function unstake(uint256[] memory requestIds) external;

If tokens are staked to an inaccessible or unintended address, there is no mechanism to recover these funds, leading to a potential loss.

BVSS
Recommendation

This issue can be mitigated through one or more of the following solutions:

  • Remove the account argument from the stake() function to ensure users can only stake tokens for themselves

  • Implement a delegated unstaking mechanism, allowing users to authorize trusted accounts to unstake on their behalf.

  • Document the risk of staking to unintended addresses, ensuring users understand the potential for permanent fund loss in such scenarios.


By addressing this inconsistency, the contract can better align user expectations with its functionality and reduce the risk of lost funds.

Remediation

PARTIALLY SOLVED: The BSX team partially fixed this finding in commit 4e2e968 by introducing a new stake() function without the account argument to minimize the risk of users mistakenly staking to an unintended address. This provides a safer option for regular staking operations. However, the original stake() function with the account argument remains accessible, leaving the potential for incorrect usage. To fully address the risk, it is recommended to document the intended use of the original stake() function and clearly warn users of the potential consequences of staking to an unintended or inaccessible address.

Remediation Hash

7.3 Missing Input Validation

// Informational

Description

During the security assessment, several functions in the smart contracts were found to lack adequate input validation. Without proper checks, critical parameters may be set to invalid, undesired, or unrealistic values, which could lead to vulnerabilities, unexpected behavior, or erroneous contract states.


For instance, the initialize() function does not validate its inputs. Examples of recommended validation include:

require(_owner != address(0), "Owner cannot be zero address");
require(address(_stakingToken) != address(0), "Staking token cannot be zero address");
require(_rewardRate > 0, "Reward rate must be greater than zero");
require(_rewardPeriod > 0, "Reward period must be greater than zero");

Failure to validate inputs can result in critical issues:

  • If _rewardPeriod is set to 0, the getAccumulatedRewardPerToken() function would attempt a division by zero, leading to contract failures.

  • Assigning zero addresses to _owner or _stakingToken would result in misconfigured or unusable contract states.


While initialize() is a clear example, other functions across the codebase may similarly lack proper input validation. A comprehensive review should be conducted to identify and address these gaps.

BVSS
Recommendation

To mitigate these issues, implement robust input validation across all critical functions, including constructors and initialization functions. For example:

  • Ensure parameters meet logical constraints (e.g., non-zero addresses, positive values).

  • Prevent division-by-zero and other edge cases through early checks.


A thorough review of the codebase is recommended to identify all functions requiring input validation. Establishing consistent validation standards enhances contract security, reliability, and predictability, while reducing the risk of vulnerabilities caused by invalid inputs.

Remediation

SOLVED: The BSX team fixed this finding in commit 9f6d709 by introducing more validations to the user inputs.

Remediation Hash

7.4 Unsafe ERC20 Operations

// Informational

Description

The StakingVault contract contains an instance of an ERC20 operation that does not utilize OpenZeppelin's SafeERC20 library, which is considered best practice for securely handling token transfers. Specifically, the stake() function uses the raw transferFrom() function instead of the safer safeTransferFrom() provided by SafeERC20. While the unstake() function correctly uses safeTransfer(), this inconsistency introduces potential risks if the stakingToken does not adhere strictly to the ERC20 standard (e.g., tokens that do not revert on failed transfers).


Instance in the stake() function:

IERC20(stakingToken).transferFrom(msg.sender, address(this), amount);

The risk of this finding is reduced from low to informational because:

  • The stakingToken is intended to be the BSX token, which conforms strictly to the ERC20 standard and returns proper boolean values for transferFrom.

  • The project does not support other tokens as the staking token, reducing the likelihood of compatibility issues.

BVSS
Recommendation

Update the stake() function to use OpenZeppelin's safeTransferFrom() for consistency and to ensure secure token handling.

Remediation

SOLVED: The BSX team fixed this finding in commit 0c8fb29 by implementing the recommended solution.

Remediation Hash

7.5 Owner Can Renounce Ownership

// Informational

Description

It was identified that the StakingVault contract inherited from OpenZeppelin's Ownable2StepUpgradeable library. In the Ownable2StepUpgradeable contracts, the renounceOwnership() function is used to renounce the Owner permission. Renouncing ownership before transferring would result in the contract having no owner, eliminating the ability to call privileged functions.

/**
 * @dev Leaves the contract without owner. It will not be possible to call
 * `onlyOwner` functions. Can only be called by the current owner.
 *
 * NOTE: Renouncing ownership will leave the contract without an owner,
 * thereby disabling any functionality that is only available to the owner.
 */
function renounceOwnership() public virtual onlyOwner {
    _transferOwnership(address(0));
}
Proof of Concept

The following Foundry test was used in order to prove the aforementioned issue:

function test_renounceOwnership() public {
    vm.assertEq(stakingVault.owner(), owner);
    vm.assertEq(stakingVault.pendingOwner(), address(0));

    vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this)));
    stakingVault.renounceOwnership();
    vm.assertEq(stakingVault.owner(), owner);
    vm.assertEq(stakingVault.pendingOwner(), address(0));

    vm.prank(owner);
    stakingVault.renounceOwnership();
    vm.assertEq(stakingVault.owner(), address(0));
    vm.assertEq(stakingVault.pendingOwner(), address(0));
}

To run it, just execute the following forge command:

forge test --mt test_renounceOwnership -vvvv

Observe that the test passes and the ownership has been renounced (address(0)).

Ran 1 test for test/StakingVault.t.sol:StakingVaultTest
[PASS] test_renounceOwnership() (gas: 33390)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.14ms (75.00µs CPU time)

Ran 1 test suite in 156.22ms (1.14ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
BVSS
Recommendation

It is recommended that the Owner cannot call renounceOwnership() without first transferring ownership to another address. In addition, if a multi-signature wallet is used, the call to the renounceOwnership() function should be confirmed for two or more users.

Remediation

ACKNOWLEDGED: The BSX team indicated that they acknowledge the risk of this security finding, indicating that renouncing ownership in the future is a deliberate and desired aspect of the contract's functionality.

7.6 Cache Array Length Outside of Loop

// Informational

Description

When the length of an array is not cached outside of a loop, the Solidity compiler reads the length of the array during each iteration. For storage arrays, this results in an extra sload operation (100 additional gas for each iteration except the first). For memory arrays, this results in an extra mload operation (3 additional gas for each iteration except the first).


Detecting loops that use the length member of a storage array in their loop condition without modifying it can reveal opportunities for optimization. See the following example in StakingVault.sol:

function cancelUnstakeRequests(uint256[] memory requestIds) external nonReentrant updateReward(msg.sender) {
  if (requestIds.length == 0) revert EmptyRequestIds();

  address account = msg.sender;
  uint256 amount = 0;
  for (uint256 i = 0; i < requestIds.length; i++) {
    ...
}
BVSS
Recommendation

Cache the length of the array in a local variable outside the loop to optimize gas usage. This reduces the number of read operations required during each iteration of the loop. See the example fixes below:

function cancelUnstakeRequests(uint256[] memory requestIds) external nonReentrant updateReward(msg.sender) {
  if (requestIds.length == 0) revert EmptyRequestIds();

  address account = msg.sender;
  uint256 amount = 0;
  uint256 length = requestIds.length;
  for (uint256 i = 0; i < length; i++) {
    ...
}
Remediation

SOLVED: The BSX team fixed this finding in commit 0c8fb29 by implementing the recommended solution.

Remediation Hash

7.7 Unlocked Pragma Compiler

// Informational

Description

The files in scope currently use floating pragma version ^0.8.25, which means that the code can be compiled by any compiler version that is greater than or equal to 0.8.25, and less than 0.9.0. It is recommended that contracts should be deployed with the same compiler version and flags used during development and testing. Locking the pragma helps to ensure that contracts do not accidentally get deployed using another pragma. For example, an outdated pragma version might introduce bugs that affect the contract system negatively.

Score
Recommendation

Lock the pragma version to the same version used during development and testing.

Remediation

ACKNOWLEDGED: The BSX team indicated that they acknowledge the risk of this security finding.

7.8 Consider Using Named Mappings

// Informational

Description

The project accepts using a Solidity compiler version greater than 0.8.18, which supports named mappings. Using named mappings can improve the readability and maintainability of the code by making the purpose of each mapping clearer. This practice helps developers and auditors understand the mappings' intent more easily.

Score
Recommendation

Consider refactoring the mappings to use named arguments, which will enhance code readability and make the purpose of each mapping more explicit.

For example, on StakingVault.sol, instead of declaring:

mapping(address => uint256) public lastAccumulatedRewards;

It could be declared as:

mapping(address user => uint256 amount) public lastAccumulatedRewards;
Remediation

SOLVED: The BSX team fixed this finding in commit 0c8fb29 by implementing the recommended solution.

Remediation Hash

7.9 Unused Import

// Informational

Description

During the security assessment of the smart contracts, an instance of unused import was found. Unused imports can clutter the codebase, reducing readability and potentially leading to confusion during development or auditing. Additionally, unnecessary imports can slightly increase the compiled contract's bytecode size, potentially affecting deployment and execution costs.


In src/StakingVault.sol:

import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
Score
Recommendation

For better clarity, consider removing all unused code.

Remediation

SOLVED: The BSX team fixed this finding in commit 0c8fb29 by implementing the recommended solution.

Remediation Hash

7.10 Use Calldata For Function Arguments Not Mutated

// Informational

Description

The functions cancelUnstakeRequests() and unstake() accept requestIds as a memory array, even though the array is not mutated in the external function itself. This results in unnecessary gas overhead when copying data from calldata to memory during the abi.decode() process.


Using calldata directly for such arguments bypasses the copying loop, reducing gas costs, especially for larger arrays.


Optimizing cancelUnstakeRequests() and unstake() to accept the requestIds array as calldata instead of memory can reduce gas costs for external calls. The savings grow with the size of the input array.

Score
Recommendation

Consider updating the function signatures as follows:

function cancelUnstakeRequests(uint256[] calldata requestIds) external nonReentrant updateReward(msg.sender) {
    // Function logic remains the same
}

function unstake(uint256[] calldata requestIds) public nonReentrant {
    // Function logic remains the same
}

By switching the argument type to calldata, the requestIds array is read directly from the transaction's calldata, eliminating unnecessary memory allocations and reducing gas costs.

Remediation

SOLVED: The BSX team fixed this finding in commit 0c8fb29 by implementing the recommended solution.

Remediation Hash

7.11 Style Guide Optimizations

// Informational

Description

In Solidity development, adhering to the official style guide is best practice to ensure code consistency, readability, and maintainability. Throughout the contracts, there are several instances where the code does not follow these guidelines. Some examples include:

  • In BsxToken, the NAME, SYMBOL and TOTAL_SUPPLY internal constants do not start with an underscore.

  • The unstake() function is declared as public, but is never used inside the contract.

  • In BsxToken, the TOTAL_SUPPLY constant does not use scientific notation.

  • Redundant IERC20 casting in stake() for calling the transferFrom() function.

Score
Recommendation

Apply the following style guide improvements throughout the codebase:

  • In BsxToken, the declared internal constants should start with an underscore: _NAME, _SYMBOL and _TOTAL_SUPPLY.


  • In StakingVault.sol, the unstake() function could be declared as external.


  • For TOTAL_SUPPLY constant could use scientific notation (1e9).


  • In StakingVault.sol, remove the redundant IERC20 casting in the stake() function (also switched from transferFrom to safeTransferFrom as indicated in the finding Unsafe ERC20 Operations).

Remediation

SOLVED: The BSX team fixed this finding in commit 0c8fb29 by implementing the recommended solution.

Remediation Hash

7.12 Redundant Inheritance

// Informational

Description

The BsxToken contract includes redundant inheritance, which unnecessarily increases the complexity of the contract. Specifically, it inherits from ERC20, which is already inherited by ERC20Permit and ERC20Votes:

contract BsxToken is ERC20, ERC20Permit, ERC20Votes {

This creates a redundancy that can complicate contract structure and increase the risk of potential issues related to Solidity's C3 linearization for resolving multiple inheritance.

Score
Recommendation

To simplify the contract and eliminate potential linearization issues, consider removing the redundant ERC20 inheritance from BsxToken:

contract BsxToken is ERC20Permit, ERC20Votes {
Remediation

ACKNOWLEDGED: The BSX team indicated that they acknowledge the risk of this security finding indicating that an explicit design was intentionally preferred.

8. Automated Testing

Static Analysis Report

Description

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.

All issues identified by Slither were proved to be false positives or have been added to the issue list in this report.

Output

Slither Output

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.