Prepared by:
HALBORN
Last Updated 11/25/2024
Date of Engagement by: November 21st, 2024 - November 22nd, 2024
100% of all REPORTED Findings have been addressed
All findings
12
Critical
0
High
0
Medium
0
Low
1
Informational
11
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.
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.
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
EXPLOITABILIY METRIC () | METRIC VALUE | NUMERICAL 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 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL 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 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
0
High
0
Medium
0
Low
1
Informational
11
Security analysis | Risk level | Remediation Date |
---|---|---|
Incompatibilities With fee-on-transfer Tokens | Low | Solved - 11/25/2024 |
Potential for Stuck Funds in Staking Process | Informational | Partially Solved - 11/25/2024 |
Missing Input Validation | Informational | Solved - 11/25/2024 |
Unsafe ERC20 Operations | Informational | Solved - 11/25/2024 |
Owner Can Renounce Ownership | Informational | Acknowledged - 11/25/2024 |
Cache Array Length Outside of Loop | Informational | Solved - 11/25/2024 |
Unlocked Pragma Compiler | Informational | Acknowledged - 11/25/2024 |
Consider Using Named Mappings | Informational | Solved - 11/25/2024 |
Unused Import | Informational | Solved - 11/25/2024 |
Use Calldata For Function Arguments Not Mutated | Informational | Solved - 11/25/2024 |
Style Guide Optimizations | Informational | Solved - 11/25/2024 |
Redundant Inheritance | Informational | Acknowledged - 11/25/2024 |
// Low
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.
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.
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.
// Informational
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.
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.
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.
// Informational
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.
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.
SOLVED: The BSX team fixed this finding in commit 9f6d709
by introducing more validations to the user inputs.
// Informational
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.
Update the stake()
function to use OpenZeppelin's safeTransferFrom()
for consistency and to ensure secure token handling.
SOLVED: The BSX team fixed this finding in commit 0c8fb29
by implementing the recommended solution.
// Informational
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));
}
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)
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.
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.
// Informational
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++) {
...
}
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++) {
...
}
SOLVED: The BSX team fixed this finding in commit 0c8fb29
by implementing the recommended solution.
// Informational
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.
Lock the pragma version to the same version used during development and testing.
ACKNOWLEDGED: The BSX team indicated that they acknowledge the risk of this security finding.
// Informational
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.
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;
SOLVED: The BSX team fixed this finding in commit 0c8fb29
by implementing the recommended solution.
// Informational
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";
For better clarity, consider removing all unused code.
SOLVED: The BSX team fixed this finding in commit 0c8fb29
by implementing the recommended solution.
// Informational
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.
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.
SOLVED: The BSX team fixed this finding in commit 0c8fb29
by implementing the recommended solution.
// Informational
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.
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
).
SOLVED: The BSX team fixed this finding in commit 0c8fb29
by implementing the recommended solution.
// Informational
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.
To simplify the contract and eliminate potential linearization issues, consider removing the redundant ERC20
inheritance from BsxToken
:
contract BsxToken is ERC20Permit, ERC20Votes {
ACKNOWLEDGED: The BSX team indicated that they acknowledge the risk of this security finding indicating that an explicit design was intentionally preferred.
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.
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.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed