stake.link

stake.link
DeFiHardhatBridge
27,500 USDC
View results
Submission Details
Severity: low
Valid

Update Process on Secondary Chains Can Get Stuck

Summary

The protocol relies on SDLPoolPrimary (deployed on Ethereum) to be informed about effective balance changes on secondary chains. This is essential for keeping track of the rewards it needs to distribute to stakers on those chains. However, the protocol can get stuck in a situation where it's never informed about these changes due to a lack of rewards to distribute.

Vulnerability Details

The process begins with a call to SDLCCIPControllerPrimary.distributeRewards() made by the RewardsInitiator, which is triggered by Chainlink automation. This function calculates the total amount of tokens to distribute to each chain:

for (uint256 i = 0; i < tokens.length; ++i) {
address token = tokens[i];
uint256 tokenBalance = IERC20(token).balanceOf(address(this));
// @audit-info If we get a wrapped token for a given token, wrap all the balance we have and work with it instead
address wrappedToken = wrappedRewardTokens[token];
if (wrappedToken != address(0)) {
IERC677(token).transferAndCall(wrappedToken, tokenBalance, "");
tokens[i] = wrappedToken;
tokenBalance = IERC20(wrappedToken).balanceOf(address(this));
}
uint256 totalDistributed;
for (uint256 j = 0; j < numDestinations; ++j) {
uint64 chainSelector = whitelistedChains[j];
// @audit-info If it's the last chain in the array of supported chains, distribute the remaining rewards
uint256 rewards = j == numDestinations - 1
? tokenBalance - totalDistributed
: (tokenBalance * reSDLSupplyByChain[chainSelector]) / totalRESDL;
// @audit-issue Will crash while totalRESDL is 0
distributionAmounts[j][i] = rewards;
totalDistributed += rewards;
}
}
for (uint256 i = 0; i < numDestinations; ++i) {
// @audit-info If distributionAmounts[i][j] == 0, the underlying function won't send the ccip
_distributeRewards(whitelistedChains[i], tokens, distributionAmounts[i]);
}

Note how uint256 rewards is calculated based on reSDLSupplyByChain for a specific chain. If reSDLSupplyByChain[chainSelector] is 0 and the chain is not the last one in whitelistedChains, the rewards will be zero. Additionally, SDLCCIPControllerPrimary._distributeRewards has a check where it returns early if there are no rewards:

// ...
for (uint256 i = 0; i < _rewardTokens.length; ++i) {
if (_rewardTokenAmounts[i] != 0) {
numRewardTokensToTransfer++;
}
}
if (numRewardTokensToTransfer == 0) return;
// ...

In this scenario, the protocol will not send the ccip message to the secondary chain, thus reSDLSupplyByChain will not be updated, keeping the rewards at zero.

Impact

The impact is high as this can cause the secondary chain to be in an eternal waiting state for the update process, not informing the primary chain of changes on the reSDL supply. However, this can be easily fixed by transferring some reSDL tokens to that secondary chain using the bridge. This action makes SDLCCIPControllerPrimary.handleOutgoingRESDL modify the reSDLSupplyByChain, thereby starting the update process:

function handleOutgoingRESDL(uint64 _destinationChainSelector, address _sender, uint256 _tokenId)
external
override
onlyBridge
returns (address, ISDLPool.RESDLToken memory)
{
if (whitelistedDestinations[_destinationChainSelector] == address(0)) revert InvalidDestination();
// @audit-info ISDLPoolPrimary accounts for all the reSDL balance based on this _tokenId
ISDLPool.RESDLToken memory reSDLToken =
ISDLPoolPrimary(sdlPool).handleOutgoingRESDL(_sender, _tokenId, address(this));
// @audit-info CCIP Controller primary keeps an internal accounting for destination chains, can this get out of sync with the SDLPoolPrimary?
reSDLSupplyByChain[_destinationChainSelector] += reSDLToken.amount + reSDLToken.boostAmount;
return (whitelistedDestinations[_destinationChainSelector], reSDLToken);
}

Tools Used

Manual Review

Recommendations

It is recommended to check if an update is needed or not in the SDLPoolCCIPControllerSecondary.checkUpkeep(bytes calldata) function:

function checkUpkeep(bytes calldata) external view returns (bool, bytes memory) {
return (ISDLPoolSecondary(sdlPool).shouldUpdate(), "0x");
}

Since the underlying call to the pool only checks for flags to be set appropriately, it shouldn't incur a huge expenditure of gas and would allow the updates to be performed as expected.

Updates

Lead Judging Commences

0kage Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

first-reward-update

Support

FAQs

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