-
Notifications
You must be signed in to change notification settings - Fork 10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
WIP: Zellic audit 2024-10 #94
Closed
Closed
Conversation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Below we provide our recommendations for gas optimizations. 1) In several functions, the length of an array can be cached to save gas in loops. The list of functions where this optimization can be applied is given below: In the ServiceNodeContribution contract • reserved.length in the _updateReservedContributors function In the ServiceNodeRewards contract • contributors.length in the addBLSPublicKey function • _serviceNodes[serviceNodeID].contributors.length in the _initiateRemoveBLSPublicKey function • nodes.length and node.contributors.length in the seedPublicKeyList function Also, the index incrementing for the loop can be made unchecked to optimize gas usage. 2) The _contributeFunds function can be updated to avoid calling _updateBeneficiary when the staker is being added for the first time, as shown in the example below: ``` if (contributions[caller] == 0) _contributorAddresses.push(IServiceNodeRewards.Staker(caller, beneficiary)); } else { _updateBeneficiary(caller, beneficiary); } ``` 3) The _reset() function can be optimized. When status == Status.Finalized, it is unnecessary to call removeAndRefundContributor for each address in the _contributorAddresses array, since in this case, the entire array should simply be deleted and the contributions and contributionTimestamp should be set to zero. If the state is not Finalized, there is also no need to call removeAndRefundContributor for every element in the _contributorAddresses just to delete a single element. Currently, the removeAndRefundContributor function searches for the staker's address in the _contributorAddresses array, moves elements, removes the last one, and then transfers funds. In case of status == Status.Finalized, we suggest iterating through the _contributorAddresses array to refund each staker, set the contributions[staker] and contributionTimestamp[staker] to zero, and finally delete the _contributorAddresses array. Finally, call the function _updateReservedContributors with zero contributors. -- Our proposed fix is as per Zellic's recommendations 1) We cache the array length and used unchecked index increment on all our contracts (where it is safe to do so, i.e. a predictable known non-overflowing length). 2) Avoid calling updateBeneficiary on initial contribution to contract 3) Directly handle reset rather than defer to removeAndRefundContributor We can directly handle a reset by processing the request directly rather than deferring to removeAndRefundContributor which presupposes that the intention of the call is to remove exactly 1 contributor which has side-effects that waste gas as we're going to ultimately delete the contributor which nullifies said busy work.
In the _contributeFunds function, when the staking requirements are met and manualFinalize is not set, the status is set to Status.WaitForFinalized, awaiting the operator to call the finalize function. ``` function _contributeFunds(address caller, address beneficiary, uint256 amount) private { [...] if (currTotalContribution == stakingRequirement) { emit Filled(_serviceNodeParams.serviceNodePubkey, operator); status = Status.WaitForFinalized; } // NOTE: Transfer funds from sender to contract emit NewContribution(caller, amount); SENT.safeTransferFrom(caller, address(this), amount); // NOTE: Auto finalize the node if valid if (status == Status.WaitForFinalized && !manualFinalize) { _finalize(); } } ``` However, during this waiting period, a user can still remove their contributions, as the withdrawContribution function has no restrictions during this stage. This leads to a situation where the contract remains in the Status.WaitForFinalized status, even though the staking requirement is no longer met after the withdrawal. ``` function withdrawContribution() external { if (msg.sender == operator) { _reset(); return; } uint256 timeSinceLastContribution = block.timestamp - contributionTimestamp[msg.sender]; if (timeSinceLastContribution < WITHDRAWAL_DELAY) revert WithdrawTooEarly(contributionTimestamp[msg.sender], block.timestamp, WITHDRAWAL_DELAY); uint256 refundAmount = removeAndRefundContributor(msg.sender); if (refundAmount > 0) emit WithdrawContribution(msg.sender, refundAmount); } ``` Impact The finalize function remains available for calling, even if the staking requirements are not met. This allows the operator to attempt using the current collected contributions to add a new service node. However, even if the contract balance is sufficient, the addBLSPublicKey of the ServiceNodeRewards contract, which is called during node registration, will revert. This is because the addBLSPublicKey function performs a check to ensure that the total contributions from the current contributors match the stakingRequirement. ``` function addBLSPublicKey( BN256G1.G1Point memory blsPubkey, BLSSignatureParams memory blsSignature, ServiceNodeParams memory serviceNodeParams, Contributor[] memory contributors ) external whenNotPaused whenStarted { [...] for (uint256 i = 0; i < contributors.length; i++) totalAmount += contributors[i].stakedAmount; if (totalAmount != stakingRequirement) revert ContributionTotalMismatch(stakingRequirement, totalAmount); [...] } ``` As the status is still Status.WaitForFinalized, new contributors cannot add funds via the contributeFunds call, because this function reverts if the current status is not Status.WaitForOperatorContrib or Status.OpenForPublicContrib. Thus, the only way to fix the status would be to reset all the current contributions and request all the contributors to contribute again. A malicious contributor could thus carry out the attack numerous times, leading to bad user experience for the other contributors and the contribution not ever being staked. Recommendations We recommend changing the status back to Status.OpenForPublicContrib in the withdrawContribution call if the current status is Status.WaitForFinalized and the withdraw amount decreases the contribution from stakingRequirement. -- Our proposed fix is as per Zellic's recommendation to revert status back to being open for contribution on withdraw.
The updateBeneficiary function allows a contributor to change the current beneficiary address that receives the reward. This function updates the Staker object in the _contributorAddresses mapping, which is associated with the contributor. ``` function updateBeneficiary(address newBeneficiary) external { _updateBeneficiary(msg.sender, newBeneficiary); } function _updateBeneficiary(address stakerAddr, address newBeneficiary) private { address desiredBeneficiary = newBeneficiary == address(0) ? stakerAddr : newBeneficiary; address oldBeneficiary = address(0); bool updated = false; uint256 length = _contributorAddresses.length; for (uint256 i = 0; i < length; i++) { IServiceNodeRewards.Staker storage staker = _contributorAddresses[i]; [...] oldBeneficiary = staker.beneficiary; staker.beneficiary = desiredBeneficiary; break; } [...] } ``` Impact However, the issue arises if the updateBeneficiary function is called during Status.Finalized, because all information about contributors, including the beneficiary addresses, has already been provided to the ServiceNodeRewards contract for new node registration. Any beneficiary address updated after finalization will not be applied to the node, as the ServiceNodeRewards contract does not permit updates to the beneficiary information after registration. Recommendations We recommend restricting the updateBeneficiary function during the Status.Finalized phase to avoid misleading stakers that the beneficiary addresses has been updated. -- Our proposed fix is as per the recommendations, we allow a beneficiary update only when contract is in a valid state. Updating the beneficiary after the contract is finalized has no effect because the beneficiaries have already been committed to the contract and are locked in. This is preventable by checking the status of the contract before updating the contract. Note that when the operator contributes, the contract transitions into `OpenForPublicContrib` immediately before the beneficiary is updated. This then means that the valid states that are observed for a beneficiary to be updatable is exactly 2 states, `OpenForPublicContrib` and `WaitForFinalized`.
Small fix for a minor issue regarding outdated documentation raise by Zellic in the investor staking contracts.
Merged in #93 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Remediations as recommended by Zellic. WIP whilst currently under review