Platform: Code4rena
Start Date: 11/11/2022
Pot Size: $90,500 USDC
Total HM: 52
Participants: 92
Period: 7 days
Judge: LSDan
Total Solo HM: 20
Id: 182
League: ETH
Rank: 33/92
Findings: 2
Award: $446.96
š Selected for report: 1
š Solo Findings: 0
394.9268 USDC - $394.93
https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/SavETHVault.sol#L206-L207 https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/SavETHVault.sol#L209
Liquid staking manager call function withdrawETHForStaking(address _smartWallet, uint256 _amount)
to withdraw ETH for staking. It's manager's responsibility to set the correct _smartWallet
address. However, there is no way to guarantee this. If a typo (or any other reasons) leads to a non-zero non-existent _smartWallet
address, this function won't be able to detect the problem, and the ETH transfer statement will always return true
. This will result in the ETH permanently locked to a non-existent account.
Liquid staking manager call function withdrawETHForStaking(address _smartWallet, uint256 _amount)
with a non-zero non-existent _smartWallet
address and some _amount
of ETH. Function call will succeed but the ETH will be locked to the non-existent _smartWallet
address.
Manual audit.
The problem can be solved if we can verify the _smartWallet
is a valid existent smartWallet before ETH transfer. The easiest solution is to verify the smartWallet has a valid owner since the smart wallet we are using is ownable. So, just add the checking owner code before ETH transfer.
#0 - c4-judge
2022-11-21T21:46:11Z
dmvt marked the issue as primary issue
#1 - dmvt
2022-11-21T22:43:06Z
As with #308, I recommend that the sponsor review all of the duplicates of this issue.
#2 - c4-sponsor
2022-11-28T16:46:53Z
vince0656 marked the issue as sponsor confirmed
#3 - c4-judge
2022-12-02T19:56:11Z
dmvt marked the issue as selected for report
#4 - c4-judge
2022-12-02T19:56:18Z
dmvt marked the issue as satisfactory
#5 - trust1995
2022-12-06T21:29:31Z
I believe lack of existence check on passed address is very deep into QA/L territory, as has been standardized in the org - https://github.com/code-423n4/org/issues/53 Requires a massive user mistake, imo rewarding these kinds of findings as M dilutes the pool and does injustice to proper Ms. @GalloDaSballo
#6 - dmvt
2022-12-07T10:03:30Z
See my response in the post-judging qa discussion.
š Selected for report: 0xSmartContract
Also found by: 0x4non, 0xNazgul, 0xRoxas, 0xdeadbeef0x, 0xmuxyz, 9svR6w, Awesome, Aymen0909, B2, Bnke0x0, CloudX, Deivitto, Diana, Franfran, IllIllI, Josiah, RaymondFam, ReyAdmirado, Rolezn, Sathish9098, Secureverse, SmartSek, Trust, Udsen, a12jmx, aphak5010, brgltd, bulej93, c3phas, ch0bu, chaduke, chrisdior4, clems4ever, cryptostellar5, datapunk, delfin454000, fs0c, gogo, gz627, hl_, immeas, joestakey, lukris02, martin, nogo, oyc_109, pashov, pavankv, peanuts, pedr02b2, rbserver, rotcivegaf, sahar, sakman, shark, tnevler, trustindistrust, zaskoh, zgo
52.0338 USDC - $52.03
In ETH deposit functions, it has a redundant parameter uint256 _amount
that has the same value of msg.value
. The function also requires that _amount
must equal to msg.value
, otherwise revert. This will cost more gas and result in transaction failure on user/staker's typos of _amount
. Refactoring the relevant functions to cleaner code improves efficiency and saves gas.
Instances (3)
Instance 1: file: contracts/liquid-staking/SavETHVault.sol
Function depositETHForStaking() retactoring:
function depositETHForStaking(bytes calldata _blsPublicKeyOfKnot) public payable returns (uint256) {
uint256 _amount = msg.value
Instance 2: file: contracts/liquid-staking/StakingFundsVault.sol
Function depositETHForStaking() refactoring:
function depositETHForStaking(bytes calldata _blsPublicKeyOfKnot) public payable returns (uint256) {
uint256 _amount = msg.value
Instance 3: file: contracts/liquid-staking/GiantPoolBase.sol#L34 https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/GiantPoolBase.sol#L34-L48
file: contracts/liquid-staking/LiquidStakingManager.sol
Function rotateEOARepresentative and rotateEOARepresentativeOfNodeRunner have the same repeated logic that can be refactored as follows(also introducing custom errors to save gas):
// introduce custom errors for gas saving error ZeroAddressRepresentative(); error InvalidSmartWallet(); error NotAllKNOTsMinted(); error InvalidRotationToSameEOA(); error NodeRunnerIsBanned(); /// @notice Rotate representative /// @param _nodeRunner address of the node runner /// @param _newRepresentative address of the new representative to be appointed for the node runner function _rotateEOARepresentative( address _nodeRunner, address _newRepresentative ) private { if (_newRepresentative == address(0)) revert ZeroAddressRepresentative(); address smartWallet = smartWalletOfNodeRunner[_nodeRunner]; if (smartWallet == address(0)) revert InvalidSmartWallet(); if (stakedKnotsOfSmartWallet[smartWallet] != 0) revert NotAllKNOTsMinted(); if (smartWalletRepresentative[smartWallet] == _newRepresentative) revert InvalidRotationToSameEOA(); // unauthorize old representative _authorizeRepresentative( smartWallet, smartWalletRepresentative[smartWallet], false ); // authorize new representative _authorizeRepresentative(smartWallet, _newRepresentative, true); } /// @notice Allow a node runner to rotate the EOA representative they use for their smart wallet /// @dev if any KNOT is staked for a smart wallet, no rep can be appointed or updated until the derivatives are minted /// @param _newRepresentative address of the new representative to be appointed function rotateEOARepresentative(address _newRepresentative) external { if(isNodeRunnerBanned(msg.sender)) revert NodeRunnerIsBanned(); _rotateEOARepresentative(msg.sender, _newRepresentative); } /// @notice Allow DAO to rotate representative in the case that node runner is not available (to facilitate staking) /// @param _nodeRunner address of the node runner /// @param _newRepresentative address of the new representative to be appointed for the node runner function rotateEOARepresentativeOfNodeRunner( address _nodeRunner, address _newRepresentative ) external onlyDAO { _rotateEOARepresentative(_nodeRunner, _newRepresentative); }
File: contracts/liquid-staking/LiquidStakingManager.sol
Message on Line 940 should be "DAO staking funds vault balance must be 4 ether".
File: contracts/liquid-staking/LiquidStakingManager.sol 940: require(stakingFundsLP.totalSupply() == 4 ether, "DAO staking funds vault balance must be at least 4 ether");
Further, the above require()
statement should be converted to a custom error for gas saving:
error DAOStakingFundsMustBe4Ether(); if (stakingFundsLP.totalSupply() != 4 ether) revert DAOStakingFundsMustBe4Ether();
In File contracts/liquid-staking/LiquidStakingManager.sol
, the business logic of updating DAO revenue commission is spreaded in function _updateDAORevenueCommission
and updateDAORevenueCommission
. As _updateDAORevenueCommission
will be called in different places, it's better to merge the updating logic into one place: _updateDAORevenueCommission
.
Refactor _updateDAORevenueCommission()
and updateDAORevenueCommission()
to the following code (also introducing custom error to save gas):
# File: contracts/liquid-staking/LiquidStakingManager.sol error InvalidCommission(); error SameCommissionPercentage(); /// @dev Internal method for dao to trigger updating commission it takes of node runner revenue function _updateDAORevenueCommission(uint256 _commissionPercentage) internal { if(_commissionPercentage > MODULO) revert InvalidCommission(); if(_commissionPercentage == daoCommissionPercentage) revert SameCommissionPercentage(); emit DAOCommissionUpdated(daoCommissionPercentage, _commissionPercentage); daoCommissionPercentage = _commissionPercentage; } /// @notice Allow DAO to take a commission of network revenue function updateDAORevenueCommission(uint256 _commissionPercentage) external onlyDAO { _updateDAORevenueCommission(_commissionPercentage); }
file: contracts/liquid-staking/SavETHVault.sol
Line 170 is redundant since dETHDetails
is defined as a storage variable at Line 152. Any changes of dETHDetails
will be reflected in dETHForKnot[blsPublicKeyOfKnot]
.
File: contracts/liquid-staking/SavETHVault.sol 170: dETHForKnot[blsPublicKeyOfKnot] = dETHDetails;
receive()
functionInstances (4)
.transfer()
and .transfer()
have a limit of 2300 gas, .call()
may pass onto more gas. This amount of gas (2300) is enough for payment and logging but not for some other operations. For best practice, we can add a logging event in the receive()
function so that we can receive a notification whenever we receive some ETH.
Instance 1:
file: contracts/syndicate/Syndicate.sol
receive()
function: https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/syndicate/Syndicate.sol#L352-L354
Instance 2:
file: contracts/syndicate/LiquidStakingManager.sol
receive()
function: https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/LiquidStakingManager.sol#L629
Instance 3:
file: contracts/syndicate/SyndicateRewardsProcessor.sol
receive()
function: https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/liquid-staking/SyndicateRewardsProcessor.sol#L98
Instance 4:
file: contracts/syndicate/OwnableSmartWallet.sol
receive()
function: https://github.com/code-423n4/2022-11-stakehouse/blob/main/contracts/smart-wallet/OwnableSmartWallet.sol#L148
Solution:
Change function receive()
to:
event ReceivedEth(address _from, uint256 _value); Ā Ā /// @notice contract can receive ETH Ā Ā receive() external payable { Ā Ā Ā Ā emit ReceivedEth(msg.sender, msg.value); Ā Ā }
#0 - c4-judge
2022-12-02T19:50:23Z
dmvt marked the issue as grade-b