Prepared by:
HALBORN
Last Updated 04/25/2024
Date of Engagement by: July 1st, 2022 - August 2nd, 2022
93% of all REPORTED Findings have been addressed
All findings
15
Critical
0
High
1
Medium
3
Low
5
Informational
6
\client engaged Halborn to conduct a security audit on their smart contracts beginning on 2022-07-01 and ending on 2022-08-02. The security assessment was scoped to the smart contracts provided to the Halborn team.
The team at Halborn was provided one month 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 were mostly addressed by the \client team.
Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy regarding the scope of the smart contract audit. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of smart contracts 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.
Dynamic Analysis (foundry
)
Static Analysis(slither
)
\begin{enumerate} \item Liquid Collective Contracts Security Audit Test Scope \begin{enumerate} \item Repository: \href{https://github.com/River-Protocol/river-contracts}{River Contracts} \item Commit ID: \href{https://github.com/River-Protocol/river-contracts/tree/6c2bef46955b2e38dfebc7e135ee86b616fcbcb9}{6c2bef46955b2e38dfebc7e135ee86b616fcbcb9} \end{enumerate} \item Out-of-Scope \begin{enumerate} \item contracts/src/mock/.sol \item contracts/test/.sol \end{enumerate} \end{enumerate}
Critical
0
High
1
Medium
3
Low
5
Informational
6
Impact x Likelihood
HAL-02
HAL-01
HAL-03
HAL-05
HAL-08
HAL-09
HAL-10
HAL-06
HAL-07
HAL-04
HAL-11
HAL-12
HAL-13
HAL-14
HAL-15
Security analysis | Risk level | Remediation Date |
---|---|---|
DONATE CALL BEFORE DEPOSIT LEADS LOSS OF POSSIBLE REWARDS | High | Solved - 08/10/2022 |
ORACLE SHOULD CHECK UNDERLYING BALANCE INSTEAD OF TOTAL SUPPLY | Medium | Solved - 08/10/2022 |
DIVISION BY ZERO | Medium | Solved - 08/10/2022 |
SINGLE-STEP OWNERSHIP CHANGE | Medium | Solved - 08/10/2022 |
ACCIDENTALLY SENT ETHERS WILL GET STUCK IN PROTOCOL | Low | Risk Accepted |
MISSING REENTRANCY GUARD | Low | Solved - 08/10/2022 |
IGNORED RETURN VALUES | Low | Solved - 08/10/2022 |
LACK OF ZERO ADDRESS CHECKS | Low | Solved - 08/10/2022 |
USE OF UNNECESSARY IFADMIN MODIFIER | Low | Not Applicable |
MALICIOUS OWNER CAN ADD AN OPERATOR WITH SAME ADDRESS | Informational | - |
USE UNCHECKED KEYWORD FOR GAS OPTIMISATION | Informational | Solved - 08/10/2022 |
USE OF POST-FIX INCREMENT ON FOR LOOPS | Informational | Solved - 08/10/2022 |
UNNECESSARY ASSERT USAGE | Informational | Solved - 08/10/2022 |
IF CONDITIONS CAN BE OPTIMISED | Informational | Solved - 08/10/2022 |
UNNECESSARY ADDITION OF TRANSFER AND DEPOSIT MASKS | Informational | Acknowledged |
// High
If the totalSupply()
is zero for the Alluvial, the donate()
function will not mint any rewards.
The documentation explains the use of donate
function as below:
Allows anyone to add ethers to river without minting new shares
However, if the totalSupply()
exceeds zero, the contract tries to mint rewards for operators according to the _onEarnings
function. Therefore, the proposition above can be ignored for this case, since the donate
function calls _onDonation
and _onEarnings
functions in order.
The sharesToMint
variable will always return zero if the contract does not have any LsETH. There should be a sanity check on the contract to prevent the donating operation while totalSupply()
is zero.
For example:
Scenario-1: Bob donates 10 ETH while totalSupply
is zero. The contract mints 0 River as reward.
Scenario-2: Bob donates 10 ETH while totalSupply is 1e18. The contract transfers 47619047619047619 River to operators as reward.
PoC - Foundry Test Case:
function testDonateBeforeDeposit() public {
vm.prank(admin);
river.setGlobalFee(5000);
vm.startPrank(bob);
river.donate{value: 10 ether}(); // returns 0
function testDonateAfterDeposit() public {
vm.prank(admin);
river.setGlobalFee(5000);
vm.startPrank(bob);
river.deposit{value: 1 ether}();
river.donate{value: 10 ether}(); // returns 47619047619047619
function _onEarnings(uint256 _amount) internal override {
uint256 globalFee = GlobalFee.get();
uint256 sharesToMint = (_amount * _totalSupply() * globalFee) /
((_assetBalance() * BASE) - (_amount * globalFee));
uint256 operatorRewards = (sharesToMint * OperatorRewardsShare.get()) / BASE;
uint256 mintedRewards = _rewardOperators(operatorRewards);
_mintRawShares(TreasuryAddress.get(), sharesToMint - mintedRewards);
}
SOLVED: This issue was solved by adding another control to the contract.
Commit ID: 6a46a1b47edaa6bc90f6c269011be835dec5c341
// Medium
The reportBeacon
function reports the beacon data to the protocol to correctly validate price of underlying asset. This function also makes an internal call to the _pushToRiver
function. The contract tries to calculate the ETH balance after and before during the _pushToRiver
call. However, it uses totalSupply
instead of the current balance, which is the underlying balance.
Using total supply instead of underlying balance will lead to confusion about asset prices. The price of the asset may differ negatively, as the oracle will send less or more than the expected price.
function _pushToRiver(
uint256 _epochId,
uint128 _balanceSum,
uint32 _validatorCount,
BeaconSpec.BeaconSpecStruct memory _beaconSpec
) internal {
_clearReporting(_epochId + _beaconSpec.epochsPerFrame);
IRiverOracleInput riverAddress = IRiverOracleInput(RiverAddress.get());
uint256 prevTotalEth = riverAddress.totalSupply();
riverAddress.setBeaconData(_validatorCount, _balanceSum, bytes32(_epochId));
uint256 postTotalEth = riverAddress.totalSupply();
uint256 timeElapsed = (_epochId - LastEpochId.get()) * _beaconSpec.slotsPerEpoch * _beaconSpec.secondsPerSlot;
_sanityChecks(postTotalEth, prevTotalEth, timeElapsed);
LastEpochId.set(_epochId);
emit PostTotalShares(postTotalEth, prevTotalEth, timeElapsed, riverAddress.totalShares());
}
SOLVED: This issue was solved in the following commit:
Commit ID: 4b4ef76c93e215ceb1218d68f73a833c766fa134
// Medium
During the audit, it was determined that there is a "Division by Zero" result in the _onEarnings
function. In Solidity, when you try to set any value to zero, the execution will be reverted with an error message. Therefore, the transaction will also be reverted.
The sharesToMint
variable tries to divide (_amount * _totalSupply() * globalFee)
to ((_assetBalance() * BASE) - (_amount * globalFee))
.
If (_assetBalance() * BASE)
equals _amount * globalFee
, the denominator will be zero. As a result, division by zero will occur.
function _onEarnings(uint256 _amount) internal override {
uint256 globalFee = GlobalFee.get();
uint256 sharesToMint = (_amount * _totalSupply() * globalFee) /
((_assetBalance() * BASE) - (_amount * globalFee));
uint256 operatorRewards = (sharesToMint * OperatorRewardsShare.get()) / BASE;
uint256 mintedRewards = _rewardOperators(operatorRewards);
_mintRawShares(TreasuryAddress.get(), sharesToMint - mintedRewards);
}
SOLVED: This issue was solved by adding another denominator check to the contract.
Commit ID: 799c72d45441d6a3a0828a381149a96611ea656e
// Medium
Single-step ownership change for contracts is risky. Owner addresses in River contracts can be changed in one step due to pattern in LibOwnable
. If the owner's address is set to the wrong address, this could lead to funds being lost or locked.
When changing privileged roles, a two-step approach is recommended:
function setAdministrator(address _newAdmin) external onlyAdmin {
LibOwnable._setAdmin(_newAdmin);
}
SOLVED: This issue was solved by implementing the two-step ownership change in the contract.
Commit ID: 2c3fdc8d2fa91c045d4d7332d81ee044b7e8f3da
// Low
Allowed users can deposit
or donate
ETH to the River contract. If these users accidentally send ETH to this contract, there is no way to revert this error. River's contract has no withdraw
function. Therefore, users will not be able to retrieve their accidentally sent ETH.
RISK ACCEPTED: The risk of this finding was accepted.
Currently, this is intended behavior. The withdrawal process will be defined and implemented in details once the spec is written in stone. As we advance with unknowns, we currently have a stub withdrawal contract ready to accept all the exited funds, where the implementation will be changed to manage all this process.
// Low
To protect against cross-function re-entrancy 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 guards the function with a mutex against re-entrancy attacks.
function mint(address _recipient, uint256 _value) external {
BalanceOf.set(_recipient, BalanceOf.get(_recipient) + _value);
IRiverToken(RiverAddress.get()).transferFrom(msg.sender, address(this), _value);
}
function burn(address _recipient, uint256 _value) external {
uint256 callerUnderlyingBalance = IRiverToken(RiverAddress.get()).underlyingBalanceFromShares(
BalanceOf.get(msg.sender)
);
if (_value > callerUnderlyingBalance) {
revert BalanceTooLow();
}
uint256 sharesAmount = IRiverToken(RiverAddress.get()).sharesFromUnderlyingBalance(_value);
BalanceOf.set(msg.sender, BalanceOf.get(msg.sender) - sharesAmount);
IRiverToken(RiverAddress.get()).transfer(_recipient, sharesAmount);
}
function deposit() external payable {
_deposit(msg.sender);
}
function donate() external payable {
if (msg.value == 0) {
revert EmptyDonation();
}
_onDonation(msg.value);
emit Donation(msg.sender, msg.value);
}
SOLVED: This finding was solved for the burn()
and mint()
methods after implementing nonReentrant
modifier for these methods.
Commit ID: e9be5d57d67568b6e47ee0748945a4884c939a4d
// Low
The transferFrom
and transfer
functions are declared to return a boolean return variable after successful transfers. However, it does not return any variables during calls to the WLSETH.mint()
and WLSETH.burn()
functions. It is important to validate these return variables. In this case, calling these functions can break any integrations or composability.
function mint(address _recipient, uint256 _value) external {
BalanceOf.set(_recipient, BalanceOf.get(_recipient) + _value);
IRiverToken(RiverAddress.get()).transferFrom(msg.sender, address(this), _value);
}
function burn(address _recipient, uint256 _value) external {
uint256 callerUnderlyingBalance = IRiverToken(RiverAddress.get()).underlyingBalanceFromShares(
BalanceOf.get(msg.sender)
);
if (_value > callerUnderlyingBalance) {
revert BalanceTooLow();
}
uint256 sharesAmount = IRiverToken(RiverAddress.get()).sharesFromUnderlyingBalance(_value);
BalanceOf.set(msg.sender, BalanceOf.get(msg.sender) - sharesAmount);
IRiverToken(RiverAddress.get()).transfer(_recipient, sharesAmount);
}
SOLVED: This vulnerability was resolved by Alluvial
team after adding additional code that checks the return value from transferFrom()
method.
Commit ID: cab51608d19a44e0c165715bc6e7a970d657b19a
// Low
River Contracts have address fields in multiple functions. These functions are missing address validations. Each address should be validated and checked to be non-zero. This is also considered a best practice.
During testing, it has been found that some of these inputs are not protected against using address(0)
as the destination address.
OracleManagerV1.initOracleManagerV1::_oracle
OperatorsManagerV1.addOperator::_operator,_feeRecipient
DepositManagerV1.initDepositManagerV1::_depositContractAddress
WLSETHV1.mint::_recipient
WLSETHV1.burn::_recipient
RiverV1.setAdministrator::_newAdmin
RiverV1.setTreasury::_newTreasury
RiverV1.setAllowlist::_newAllowlist
Firewall.constructor::governor_,executor_
Firewall.changeGovernor::newGovernor
Firewall.changeExecutor::newExecutor
ELFeeRecipientV1.initELFeeRecipientV1::_riverAddress
SOLVED: The issue was solved by adding zero address checks.
Commit ID: 4a526b0a9f82537bdf582fee1475171433149216
// Low
The ifAdmin
modifier is designed for functions that can be executable by only the admin role. The isPaused
function on the TUPProxy
contract checks that if the current contract is paused or not. It returns true
or false
accordingly. However, this function has unnecessary ifAdmin
modifier. Therefore, only the contract admin can view the paused state of contract.
function isPaused() external ifAdmin returns (bool) {
return StorageSlot.getBooleanSlot(_PAUSE_SLOT).value;
}
NOT APPLICABLE: This finding is not applicable because it was designed as an intended behavior.
This was intended as the ifAdmin
has two roles:
The isPaused
case falls in the second case as it prevents any collision with the implementation (we can have an unrelated isPaused
method in the implementation and users won’t call the one in the Proxy
, only the admin will)
// Informational
// Informational
In Solidity (pragma 0.8.0 and later), adding unchecked
keyword for arithmetical operations can decrease gas usage on contracts where underflow/underflow is unrealistic. It is possible to save gas by using this keyword on multiple code locations.
function isMember(address _memberAddress) external view returns (bool) {
address[] memory members = OracleMembers.get();
for (uint256 idx = 0; idx < members.length; ++idx) {
if (members[idx] == _memberAddress) {
return true;
}
}
return false;
}
for (uint256 idx = 0; idx < operators.length; ++idx) {
uint256 operatorActiveValidatorCount = operators[idx].funded - operators[idx].stopped;
totalActiveValidators += operatorActiveValidatorCount;
validatorCounts[idx] = operatorActiveValidatorCount;
}
for (uint256 idx = 0; idx < validatorCounts.length; ++idx) {
_mintRawShares(operators[idx].feeRecipient, validatorCounts[idx] * rewardsPerActiveValidator);
}
for (uint256 idx = 0; idx < receivedPublicKeyCount; idx += 1) {
_depositValidator(publicKeys[idx], signatures[idx], withdrawalCredentials);
}
for (uint256 idx = 0; idx < _keyCount; ++idx) {
bytes memory publicKey = BytesLib.slice(
_publicKeys,
idx * ValidatorKeys.PUBLIC_KEY_LENGTH,
ValidatorKeys.PUBLIC_KEY_LENGTH
);
for (uint256 idx = 0; idx < _indexes.length; ++idx) {
uint256 keyIndex = _indexes[idx];
...
for (uint256 idx = 0; idx < arr1.length; ++idx) {
res[idx] = arr1[idx];
}
for (uint256 idx = 0; idx < arr2.length; ++idx) {
res[idx + arr1.length] = arr2[idx];
}
for (uint256 idx = 1; idx < operators.length; ++idx) {
if (
operators[idx].funded - operators[idx].stopped <
operators[selectedOperatorIndex].funded - operators[selectedOperatorIndex].stopped
) {
selectedOperatorIndex = idx;
}
}
SOLVED: This finding was solved by applying the suggestion above for all for
loops in the protocol.
Commit ID: 092c468a112955aebd8a5ed2d80507393f9836c8
// Informational
In all for loops, the index
variable is incremented using +=
. It is known that, in loops, using ++i
costs less gas per iteration than +=
. This also affects incremented variables within the loop code block.
for (uint256 idx = 0; idx < receivedPublicKeyCount; idx += 1) {
_depositValidator(publicKeys[idx], signatures[idx], withdrawalCredentials);
}
SOLVED: This finding was solved by applying the suggestion above for all for
loops in the protocol.
Commit ID: 092c468a112955aebd8a5ed2d80507393f9836c8
// Informational
The _depositValidator
function on the DepositManagerV1
contract tries to calculate depositAmount
variable correctly by using assert
. It tries to divide the DEPOSIT_SIZE
variable into 1000000000 wei
. Subsequently, it tries to multiply the depositAmount
variable with 1000000000 wei
to check this calculation if equals to value
variable, which is declared as DEPOSIT_SIZE
already.
The assert usage here does not benefit anything to the contract, since value
is hardcoded as DEPOSIT_SIZE
. The result will always be the same.
uint256 value = DEPOSIT_SIZE;
uint256 depositAmount = value / 1000000000 wei;
assert(depositAmount * 1000000000 wei == value);
SOLVED: This finding was solved by removing the unnecessary assert block.
Commit ID: 4edda32d138d85e28ceb51e1519947078177a526
// Informational
Some if conditions on the contract using unnecessary boolean comparisons. Removing these boolean comparisons can optimize gas usage during the deployment of contract.
modifier active(uint256 _index) {
if (Operators.getByIndex(_index).active == false) {
revert InactiveOperator(_index);
}
_;
}
modifier operatorFeeRecipientOrAdmin(uint256 _index) {
if (msg.sender == LibOwnable._getAdmin()) {
_;
return;
}
Operators.Operator storage operator = Operators.getByIndex(_index);
if (operator.active == false) {
revert InactiveOperator(_index);
}
if (msg.sender != operator.feeRecipient) {
revert Errors.Unauthorized(msg.sender);
}
_;
}
modifier operatorOrAdmin(uint256 _index) {
if (msg.sender == LibOwnable._getAdmin()) {
_;
return;
}
Operators.Operator storage operator = Operators.getByIndex(_index);
if (operator.active == false) {
revert InactiveOperator(_index);
}
if (msg.sender != operator.operator) {
revert Errors.Unauthorized(msg.sender);
}
_;
}
function addOperator(
string calldata _name,
address _operator,
address _feeRecipient
) external onlyAdmin {
if (Operators.exists(_name) == true) {
revert OperatorAlreadyExists(_name);
}
SOLVED: The issue was fixed by removing unnecessary boolean comparisons from their if
conditions.
Commit ID: 4e946302e7485c663613b7067c12397dc6dfc357
// Informational
The _onDeposit
internal function on the RiverV1
contract uses TRANSFER_MASK
and DEPOSIT_MASK
variables to validate if user have permission to deposit ETH to the contract or transfer ETH to allowed addresses.
The DEPOSIT_MASK
and TRANSFER_MASK
variables are equal to 0x1
and 0x0
in order. The first onlyAllowed
check tries to validate if the _depositor
have permission for both deposit and transfer operations.
(AllowlistAddress.get()).onlyAllowed(_depositor, DEPOSIT_MASK + TRANSFER_MASK);
However, addition of both these masks always returns 0x1
which is equals to DEPOSIT_MASK
. As a result, use of this mathematical operation is unnecessary.
function _onDeposit(
address _depositor,
address _recipient,
uint256 _amount
) internal override {
SharesManagerV1._mintShares(_depositor, _amount);
if (_depositor == _recipient) {
(AllowlistAddress.get()).onlyAllowed(_depositor, DEPOSIT_MASK); // this call reverts if unauthorized or denied
} else {
(AllowlistAddress.get()).onlyAllowed(_depositor, DEPOSIT_MASK + TRANSFER_MASK); // this call reverts if unauthorized or denied
(AllowlistAddress.get()).onlyAllowed(_recipient, TRANSFER_MASK);
_transfer(_depositor, _recipient, _amount);
}
}
ACKNOWLEDGED: This finding was acknowledged.
This is intended as it shows both rights are required to perform the action, but the fact that the mask is 0 means that it currently requires no special rights. This is a value that might be changed in the future depending on the protocol’s evolution and future needs.
The Alluvial team
made some changes after the audit finished. These changes were to the compound()
method that sends protocol rewards to the treasury. Before the update, any user could call the compound()
method. This method was removed from the contract.
Now, there are two methods to achieve that goal:
The new pullELFees()
method can only be called via the River contract. Also, the internal _pullELFees()
method has zero address checking and emits an event if Oracle calls the setBeaconData()
method. From the security perspective, these changes have good defensive patterns overall.
The Alluvial team
made another change to their River
and SharesManager
contracts. The purpose of this change was to correct the amount of mint. The contract used the shares instead of the amount of the underlying asset before this PR. Therefore, the minted token amount was incorrect for the depositor.
In the current situation, the SharesManager
and River
contracts use the underlying asset balance, and this solves the problem.
This small change has also been reviewed and confirmed by the Halborn team
.
Halborn used automated testing techniques to enhance coverage of certain areas of the scoped contract. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified all the contracts in the repository and was able to compile them correctly into their ABI and binary formats. 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.
As a result of the tests carried out with the Slither tool, some results were obtained and these results were reviewed by Halborn
. Based on the results reviewed, some vulnerabilities were determined to be false positives and these results were not included in the report. The actual vulnerabilities found by Slither
are already included in the report findings.
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