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.
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.
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
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
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
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.
Users do not benefit from the increased rewards despite their impact
Manual
The change timing of queuedRESDLSupplyChange
should match the change timing of effectiveBalances[user]
in the secondary chain.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.