Skip to content
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
wants to merge 4 commits into from

Conversation

Doy-lee
Copy link
Collaborator

@Doy-lee Doy-lee commented Oct 17, 2024

Remediations as recommended by Zellic. WIP whilst currently under review

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.
@Doy-lee
Copy link
Collaborator Author

Doy-lee commented Oct 23, 2024

Merged in #93

@Doy-lee Doy-lee closed this Oct 23, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant