stake.link

stake.link
DeFiHardhatBridge
27,500 USDC
View results
Submission Details
Severity: medium
Invalid

Loss of user reward due to queuedRESDLSupplyChange in the secondary chain being treated as a fixed effectiveBalance change in the primary chain

Summary

In the secondary chain, balance changes put into queue are sent to the primary chain with queuedRESDLSupplyChange. In the primary chain, this value is added to or subtracted from effectiveBalances[ccipController].
This value is important because the reward to the secondary chain is calculated based on effectiveBalances[ccipController].
However, at this time, there is a process in the secondary chain that does not finalize the effectiveBalances until the user executes it.
This time lag causes a loss of reward for the user.

Vulnerability Details

To give one concrete example, let's look at the process from the time a user mints on a secondary chain to the time he or she claims his or her reward.

1. User attempts mint

When funds are sent and an attempt is made to mint them, _queueNewLock is called.
At this time, the process is put into queue, and the queuedRESDLSupplyChange is changed, but the user's effectiveBalances is not changed.

    Lock memory lock = _createLock(_amount, _lockingDuration);
    queuedNewLocks[updateBatchIndex].push(lock);
    newLocksByOwner[_owner].push(NewLockPointer(updateBatchIndex, uint128(queuedNewLocks[updateBatchIndex].length - 1)));
    queuedRESDLSupplyChange += int256(lock.amount + lock.boostAmount);

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/sdlPool/SDLPoolSecondary.sol#L370-L373

2. A queuedRESDLSupplyChange is sent to the primary chain.

Periodically, handleOutgoingUpdate is called by SDLPoolCCIPControllerSecondary and this change is communicated to the primary chain as totalRESDLSupplyChange.

    (uint256 numNewRESDLTokens, int256 totalRESDLSupplyChange) = ISDLPoolSecondary(sdlPool).handleOutgoingUpdate();

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/ccip/SDLPoolCCIPControllerSecondary.sol#L124

    int256 reSDLSupplyChange = queuedRESDLSupplyChange;

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/sdlPool/SDLPoolSecondary.sol#L317C2-L317C2

In SDLPoolCCIPControllerPrimary.sol, this value first changes reSDLSupplyByChain[sourceChainSelector].
This is the value used to calculate the reward distribution ratio for each chain.
Then after that, handleIncomingUpdate is called.

    (uint256 numNewRESDLTokens, int256 totalRESDLSupplyChange) = abi.decode(_message.data, (uint256, int256));
    if (totalRESDLSupplyChange > 0) {
        reSDLSupplyByChain[sourceChainSelector] += uint256(totalRESDLSupplyChange);
    } else if (totalRESDLSupplyChange < 0) {
        reSDLSupplyByChain[sourceChainSelector] -= uint256(-1 * totalRESDLSupplyChange);
    }
    uint256 mintStartIndex = ISDLPoolPrimary(sdlPool).handleIncomingUpdate(numNewRESDLTokens, totalRESDLSupplyChange);

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/ccip/SDLPoolCCIPControllerPrimary.sol#L297-L305

Here, totalRESDLSupplyChange affects effectiveBalances[ccipController].
effectiveBalances[ccipController] is an important variable that determines the reward to the secondary chain.

    if (_totalRESDLSupplyChange > 0) {
        effectiveBalances[ccipController] += uint256(_totalRESDLSupplyChange);
        totalEffectiveBalance += uint256(_totalRESDLSupplyChange);
    } else if (_totalRESDLSupplyChange < 0) {
        effectiveBalances[ccipController] -= uint256(-1 * _totalRESDLSupplyChange);
        totalEffectiveBalance -= uint256(-1 * _totalRESDLSupplyChange);
    }

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/sdlPool/SDLPoolPrimary.sol#L242-L248

3. Starting distributeRewards

Suppose that distributeRewards is activated at this time. This is a process called periodically by RewardsInitiator.
First, the effectiveBalance of the controller is obtained.
This is the value of effectiveBalances[_account].

    uint256 totalRESDL = ISDLPoolPrimary(sdlPool).effectiveBalanceOf(address(this));

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/ccip/SDLPoolCCIPControllerPrimary.sol#L57C89-L57C89

function effectiveBalanceOf(address _account) external view returns (uint256) {
    return effectiveBalances[_account];
}

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/sdlPool/base/SDLPool.sol#L129-L131

This is where the reward for each chain is determined.

                : (tokenBalance * reSDLSupplyByChain[chainSelector]) / totalRESDL;

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/ccip/SDLPoolCCIPControllerPrimary.sol#L84

Here, the CCIP message is sent at this time and the reward token is sent to the second chain.

    Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(
        destination,
        0,
        rewardTokens,
        rewardTokenAmounts,
        rewardsExtraArgsByChain[_destinationChainSelector]
    );

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/ccip/SDLPoolCCIPControllerPrimary.sol#L272-L278

In SDLPoolCCIPControllerSecondary.sol, ISDLPoolSecondary(sdlPool).distributeTokens(rewardTokens); is invoked when a message is received.

            ISDLPoolSecondary(sdlPool).distributeTokens(rewardTokens);

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/ccip/SDLPoolCCIPControllerSecondary.sol#L156

The following process is outside the scope of the audit, but it will add rewardPerToken.
This means that the reward to the user has been increased.

    rewardPerToken += ((_reward * 1e18) / totalStaked);

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/RewardsPool.sol#L119

4. User executes mint.

The user invokes executeQueuedOperations at any time and _mintQueuedNewLocks is called.
Here, updateRewards(_owner) is processed first.

function _mintQueuedNewLocks(address _owner) internal updateRewards(_owner) {

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/sdlPool/SDLPoolSecondary.sol#L384C59-L384C80

This is outside the scope of this audit, but it is important and will be explained here.
For the sake of clarity, let me assume that the user is using this protocol for the first time.
At this stage, the user's effectiveBalances[_account] by mint has not increased yet, so staked=0 and newRewards=0.
Of note is the process userRewardPerTokenPaid[_account] = rewardPerToken.
rewardPerToken is the reward that was increased in the distributeRewards process.
In the withdrawableRewards formula, rewardPerToken - userRewardPerTokenPaid[_account] is calculated.
This means that the reward will remain zero even if effectiveBalances[_account] is increased until the next increase in rewardPerToken.

function updateReward(address _account) public virtual {
    uint256 newRewards = withdrawableRewards(_account) - userRewards[_account];
    if (newRewards > 0) {
        userRewards[_account] += newRewards;
    }
    userRewardPerTokenPaid[_account] = rewardPerToken;
}

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/RewardsPool.sol#L89-L95

function withdrawableRewards(address _account) public view virtual returns (uint256) {
    return
        (controller.staked(_account) * (rewardPerToken - userRewardPerTokenPaid[_account])) /
        1e18 +
        userRewards[_account];
}

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/RewardsPool.sol#L38-L43

function staked(address _account) external view override returns (uint256) {
    return effectiveBalances[_account];
}

https://github.com/Cyfrin/2023-12-stake-link/blob/549b2b8c4a5b841686fceb9c311dca9ac58225df/contracts/core/sdlPool/base/SDLPool.sol#L318-L320

To summarize the process so far, the user will not receive the increased reward to the chain, even though the funds he/she has staked have increased the reward to the chain.

Importantly, this is not an event caused by a time lag in CCIP messages.
It is caused by the fact that the queue value, which is not yet executed, is used as a fixed value in the distributed reward calculation.

The user is also free to decide when to activate executeQueuedOperations.
Some users might activate it as soon as it is allowed, while others might forget about it for a while.
Such a delay in activation could lead to a distortion of rewards throughout the chain.
For example, a chain with many users who do not execute for a long time will have a higher reward for the chain as a whole, but it will not be distributed to them; instead, other users in the chain will benefit.
It is unhealthy for non-essential factors to affect rewards.

Impact

Users do not benefit from the increased rewards despite their impact

Tools Used

Manual

Recommendations

The change timing of queuedRESDLSupplyChange should match the change timing of effectiveBalances[user] in the secondary chain.

Updates

Lead Judging Commences

0kage Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.