Summary
If there are no whitelisted chains while doing a rewards distribution, the wrapped tokens will be stuck in the controller contract.
Vulnerability Details
The SDLPoolCCIPControllerPrimary::distributeRewards function helps to distribute tokens or wrapped tokens to the corresponding whitelisted chains. The problem arises if for some reason there are NO whitelisted chains, the tokens that were converted to wrapped tokens will be stuck in the controller, those rewards will NOT be distributed and the wrapped tokens will not be able to be recovered since the controller does not have the unwrap function implemented. In SDLPoolCCIPControllerPrimary::distributeRewards we can see that in code line 73-77 the tokens are converted to wrapped tokens, then if there are no whitelisted chains the _distributeRewards function will not be executed (code line 91), leaving the wrapped tokens inside the contract:
File: SDLPoolCCIPControllerPrimary.sol
56: function distributeRewards() external onlyRewardsInitiator {
57: uint256 totalRESDL = ISDLPoolPrimary(sdlPool).effectiveBalanceOf(address(this));
58: address[] memory tokens = ISDLPoolPrimary(sdlPool).supportedTokens();
59: uint256 numDestinations = whitelistedChains.length;
60:
61: ISDLPoolPrimary(sdlPool).withdrawRewards(tokens);
62:
63: uint256[][] memory distributionAmounts = new uint256[][](numDestinations);
64: for (uint256 i = 0; i < numDestinations; ++i) {
65: distributionAmounts[i] = new uint256[](tokens.length);
66: }
67:
68: for (uint256 i = 0; i < tokens.length; ++i) {
69: address token = tokens[i];
70: uint256 tokenBalance = IERC20(token).balanceOf(address(this));
71:
72: address wrappedToken = wrappedRewardTokens[token];
73: if (wrappedToken != address(0)) {
74: IERC677(token).transferAndCall(wrappedToken, tokenBalance, "");
75: tokens[i] = wrappedToken;
76: tokenBalance = IERC20(wrappedToken).balanceOf(address(this));
77: }
78:
79: uint256 totalDistributed;
80: for (uint256 j = 0; j < numDestinations; ++j) {
81: uint64 chainSelector = whitelistedChains[j];
82: uint256 rewards = j == numDestinations - 1
83: ? tokenBalance - totalDistributed
84: : (tokenBalance * reSDLSupplyByChain[chainSelector]) / totalRESDL;
85: distributionAmounts[j][i] = rewards;
86: totalDistributed += rewards;
87: }
88: }
89:
90: for (uint256 i = 0; i < numDestinations; ++i) {
91: _distributeRewards(whitelistedChains[i], tokens, distributionAmounts[i]);
92: }
93: }
The following test shows how the controller will keep the wrapped tokens instead of the wTokenPool.
it('codehawks distributeRewards stuck wrapped tokens', async () => {
let wToken = await deploy('WrappedSDTokenMock', [token1.address])
let rewardsPool = await deploy('RewardsPoolWSD', [
sdlPool.address,
token1.address,
wToken.address,
])
let wtokenPool = (await deploy('CCIPTokenPoolMock', [wToken.address])) as CCIPTokenPoolMock
await sdlPool.addToken(token1.address, rewardsPool.address)
await controller.approveRewardTokens([wToken.address])
await controller.setWrappedRewardToken(token1.address, wToken.address)
await onRamp.setTokenPool(wToken.address, wtokenPool.address)
await offRamp.setTokenPool(wToken.address, wtokenPool.address)
await controller.connect(signers[5]).handleOutgoingRESDL(77, accounts[0], 1)
await token1.transferAndCall(rewardsPool.address, toEther(500), '0x')
assert.deepEqual(
(await controller.getWhitelistedChains()).map((d) => d.toNumber()),
[77]
)
await controller.removeWhitelistedChain(77)
assert.deepEqual(
(await controller.getWhitelistedChains()).map((d) => d.toNumber()),
[]
)
await controller.distributeRewards()
assert.equal(fromEther(await wToken.balanceOf(controller.address)), 125)
assert.equal(fromEther(await wToken.balanceOf(wtokenPool.address)), 0)
})
Since the removing (SDLPoolCCIPControllerPrimary::removeWhitelistedChain) and adding (SDLPoolCCIPControllerPrimary::addWhitelistedChain) functions are separated, there may be times when the system runs out of whitelisted chains. Moreover the SDLPoolCCIPControllerPrimary::removeWhitelistedChain function does not have a protection to leave at least one whitelisted chain, therefore procedures must be prevented while there are no whitelisted chains registered.
Impact
Wrapped tokens can get trapped in the controller contract.
Tools used
Manual review
Recommendations
If there are no whitelisted chains, then do a revert:
// File: SDLPoolCCIPControllerPrimary.sol
//
function distributeRewards() external onlyRewardsInitiator {
uint256 totalRESDL = ISDLPoolPrimary(sdlPool).effectiveBalanceOf(address(this));
address[] memory tokens = ISDLPoolPrimary(sdlPool).supportedTokens();
uint256 numDestinations = whitelistedChains.length;
++ if (nummDestinations == 0) revert();
...
...
...
}
Additionally, SDLPoolCCIPControllerPrimary::removeWhitelistedChain function should leave at least one whitelisted chain.