stake.link

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

Rewards distribution will always fail if the `SDLPrimaryPool.sol` contract has both base token and its wrapped version in `supportedTokens`

Summary

The SDLPoolCCIPControllerPrimary.sol::distributeRewards() distributes the rewards in all supported tokens across all supported chains. If a wrapped token is registered for any of the reward tokens, then the wrapped version will be used to distribute the reward instead. However, if both the base token and wrapped token are supported in the pool, the method will revert due to trying to spend the entire wrapped token's balance twice. In this case the reward distribution will always fail.

Vulnerability Details

The SDLPoolCCIPControllerPrimary.sol::distributeRewards() is called periodically by RewardsInitiator.sol contract, based on a Chainlink Automation trigger. This method is responsible for distributing the rewards in supported tokens among all supported chains.

First the method claims the rewards from SDLPrimaryPool. Next, it proceeds to determine the amounts of rewards in each token for each supported chain. To achieve it, the following helper variable is used:

uint256[][] memory distributionAmounts = new uint256[][](numDestinations);
for (uint256 i = 0; i < numDestinations; ++i) {
distributionAmounts[i] = new uint256[](tokens.length);
}

The distributionAmounts is a two-dimensional array, in which the value distributionAmounts[x][y] holds the amount of token x to be distributed on chain y. In order to populate this array, the method iterates through the list of all whitelsited chains and all supported tokens and calculates the reward value. In a nutshell, the total balance of token x gets distributed across the chains proportionally to reSDLSupplyByChain. The sum of the array distributionAmounts[x] is the total balance of token x held by the SDLPoolCCIPControllerPrimary.sol contract.

If any of the supported tokens has a registered wrapped version, then the wrapped token will be used in the place of base token.

address wrappedToken = wrappedRewardTokens[token];
if (wrappedToken != address(0)) {
IERC677(token).transferAndCall(wrappedToken, tokenBalance, "");
tokens[i] = wrappedToken;
tokenBalance = IERC20(wrappedToken).balanceOf(address(this));
}

The problem is if the tokens array already contained the wrappedToken, then the wrappedToken balance will be saved to distributionAmounts twice. Let's say that the SDLPoolPrimary.sol supported tokens is TKN and its wrapped version wTKN. In a situation like that, when attempting reward distribution and processing TKN, SDLPoolCCIPControllerPrimary.sol::distributeRewards() will replace TKN its balance with wTKN token and save it inside the distributionAmounts array. However, during processing wTKN, it will save its balance again. As such, the method will eventually try to distribute the balance of wTKN twice, which will always revert.

Adding both base token and its wrapped token to the SDLPrimaryPool.sol contract is indeed possible, as the method addToken() inherited from parent contract only checks against simple address duplicates. It can hardly be considered an owner error as well - one can easily imagine a scenario when it will be desireble to support the rewards in both base and wrapped token.

For the POC, please add the following line to the distributeRewards should work correctly with wrapped tokens test in the sdl-pool-ccip-controller-primary.test.ts file. The test will be reverting with ERC20: transfer amount exceeds balance error until the issue is addressed.

it('distributeRewards should work correctly with 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 sdlPool.addToken(wToken.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')
await controller.distributeRewards()
let requestData = await onRamp.getLastRequestData()
let requestMsg: any = await onRamp.getLastRequestMessage()
assert.equal(fromEther(await linkToken.balanceOf(controller.address)), 98)
assert.equal(fromEther(requestData[0]), 2)
assert.equal(requestData[1], controller.address)
assert.equal(ethers.utils.defaultAbiCoder.decode(['address'], requestMsg[0])[0], accounts[4])
assert.equal(requestMsg[3], linkToken.address)
assert.deepEqual(
requestMsg.tokenAmounts.map((d: any) => [d[0], fromEther(d[1])]),
[[wToken.address, 125]]
)
assert.equal(fromEther(await wToken.balanceOf(wtokenPool.address)), 125)
})

Impact

Impact is high, as the core functionality of the Protocol will be broken and the rewards will never be successfuly distributed.
Likelihood is low, as the issue occurs in a very specific scenario, which, although possible, is not very likely.
The severity is therefore estimated as medium.

Tools Used

Manual review

Recommendations

Inside the SDLPoolCCIPControllerPrimary.sol::distributeRewards() method, if both base token TKN and wrapped token wTKN are supported by the pool, the logic should look wrap the TKN to wTKN and continue to the next loop iteration.

if (wrappedToken != address(0)) {
IERC677(token).transferAndCall(wrappedToken, tokenBalance, "");
+ if(ISDLPoolPrimary(sdlPool).isTokenSupported(wrappedToken)) {
+ continue;
+ }
tokens[i] = wrappedToken;
tokenBalance = IERC20(wrappedToken).balanceOf(address(this));
}

Expand the interface ISDLPool.sol with the following method:

+ function isTokenSupported(address _token) external view returns (bool);
Updates

Lead Judging Commences

0kage Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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