Platform: Code4rena
Start Date: 25/07/2023
Pot Size: $80,100 USDC
Total HM: 18
Participants: 7
Period: 10 days
Judge: cccz
Total Solo HM: 15
Id: 268
League: ETH
Rank: 5/7
Findings: 2
Award: $0.00
🌟 Selected for report: 3
🚀 Solo Findings: 2
🌟 Selected for report: auditor0517
Data not available
Some rewards might be locked inside the contract due to the rounding loss.
_claimAndSyncRewards()
claimed the rewards from the staking contract and tracks rewardsPerShare
with the current supply.
function _claimAndSyncRewards() internal virtual { uint256 _totalSupply = totalSupply(); if (_totalSupply == 0) { return; } _claimAssetRewards(); uint256 balanceAfterClaimingRewards = rewardToken.balanceOf(address(this)); uint256 _rewardsPerShare = rewardsPerShare; uint256 _previousBalance = lastRewardBalance; if (balanceAfterClaimingRewards > _previousBalance) { uint256 delta = balanceAfterClaimingRewards - _previousBalance; // {qRewards/share} += {qRewards} * {qShare/share} / {qShare} _rewardsPerShare += (delta * one) / _totalSupply; //@audit possible rounding loss } lastRewardBalance = balanceAfterClaimingRewards; rewardsPerShare = _rewardsPerShare; }
It uses one as a multiplier and from this setting we know it has the same decimals as underlying
(thus totalSupply
).
My concern is _claimAndSyncRewards()
is called for each deposit/transfer/withdraw in _beforeTokenTransfer() and it will make the rounding problem more serious.
totalSupply = 10**6 with 18 decimals
, rewardToken
has 6 decimals.
And total rewards for 1 year are 1M rewardToken
for 1M totalSupply
._claimAndSyncRewards()
might be called every 1 min due to the frequent user actions.1000000 / 365 / 24 / 60 = 1.9 rewardToken = 1900000 wei
.1900000 * 10**18 / (1000000 * 10**18) = 1
.
So users would lose almost 50% of rewards due to the rounding loss and these rewards will be locked inside the contract.Manual Review
I think there would be 2 mitigation.
_claimAndSyncRewards()
like this.if (balanceAfterClaimingRewards > _previousBalance) { uint256 delta = balanceAfterClaimingRewards - _previousBalance; //new rewards uint256 deltaPerShare = (delta * one) / _totalSupply; //new delta per share // decrease balanceAfterClaimingRewards so remainders can be used next time balanceAfterClaimingRewards = _previousBalance + deltaPerShare * _totalSupply / one; _rewardsPerShare += deltaPerShare; } lastRewardBalance = balanceAfterClaimingRewards;
Math
#0 - c4-judge
2023-08-05T13:18:18Z
thereksfour marked the issue as primary issue
#1 - c4-sponsor
2023-08-08T19:52:19Z
tbrent marked the issue as sponsor confirmed
#2 - tbrent
2023-08-08T19:52:23Z
Mitigation option #2 seems quite good
#3 - c4-judge
2023-09-05T06:27:48Z
thereksfour marked the issue as satisfactory
#4 - c4-judge
2023-09-05T06:27:52Z
thereksfour marked the issue as selected for report
🌟 Selected for report: auditor0517
Data not available
The staked funds might be locked because the deposit/withdraw/transfer logic reverts.
In StargateRewardableWrapper
, _claimAssetRewards()
claims the accumulated rewards from the staking contract and it's called during every deposit/withdraw/transfer in _beforeTokenTransfer() and _claimAndSyncRewards().
function _claimAssetRewards() internal override { stakingContract.deposit(poolId, 0); }
And in the stargate staking contract, deposit() calls updatePool() inside the function.
function updatePool(uint256 _pid) public { PoolInfo storage pool = poolInfo[_pid]; if (block.number <= pool.lastRewardBlock) { return; } uint256 lpSupply = pool.lpToken.balanceOf(address(this)); if (lpSupply == 0) { pool.lastRewardBlock = block.number; return; } uint256 multiplier = getMultiplier(pool.lastRewardBlock, block.number); uint256 stargateReward = multiplier.mul(stargatePerBlock).mul(pool.allocPoint).div(totalAllocPoint); //@audit revert when totalAllocPoint = 0 pool.accStargatePerShare = pool.accStargatePerShare.add(stargateReward.mul(1e12).div(lpSupply)); pool.lastRewardBlock = block.number; } function deposit(uint256 _pid, uint256 _amount) public { PoolInfo storage pool = poolInfo[_pid]; UserInfo storage user = userInfo[_pid][msg.sender]; updatePool(_pid); ... }
The problem is updatePool()
reverts when totalAllocPoint == 0
and this value can be changed by stargate admin using set().
So user funds might be locked like the below.
totalAllocPoint = 10
.StargateRewardableWrapper
, some users staked their funds using deposit().totalAllocPoint = 0
now.
In the stargate contract, it's not so critical because this contract has emergencyWithdraw() to rescue funds without caring about rewards. Normal users can withdraw their funds using this function.StargateRewardableWrapper
, there is no logic to be used under the emergency and deposit/withdraw won't work because _claimAssetRewards()
reverts in updatePool() due to 0 division.Manual Review
We should implement a logic for an emergency in StargateRewardableWrapper
.
During the emergency, _claimAssetRewards()
should return 0 without interacting with the staking contract and we should use stakingContract.emergencyWithdraw()
to rescue the funds.
Error
#0 - c4-judge
2023-08-05T10:22:15Z
thereksfour changed the severity to 2 (Med Risk)
#1 - c4-judge
2023-08-05T10:22:20Z
thereksfour marked the issue as primary issue
#2 - thereksfour
2023-08-07T04:23:43Z
External requirement with specific owner behavior.
#3 - c4-sponsor
2023-08-08T19:54:49Z
tbrent marked the issue as sponsor confirmed
#4 - c4-judge
2023-09-05T06:26:10Z
thereksfour marked the issue as satisfactory
#5 - c4-judge
2023-09-05T06:26:14Z
thereksfour marked the issue as selected for report
🌟 Selected for report: auditor0517
Also found by: 0xA5DF, RaymondFam, bin2chen, carlitox477, ronnyx2017
Data not available
It's not recommended to approve type(uint256).max
for safety. We should approve a relevant amount every time.
ASSET.safeApprove(address(pool), type(uint256).max);
pool_.approve(address(stakingContract_), type(uint256).max);
Users might manipulate wrapperPostPrinc
inside the transfer hook. With the current underlyingComet
token, there is no impact as it doesn't have any hook but it's recommended to add a nonReentrant
modifier to _deposit()/_withdraw()
.
IERC20(address(underlyingComet)).safeTransferFrom(src, address(this), amount);
IERC20(address(underlyingComet)).safeTransfer(dst, (amount / 10) * 10);
feeds[i].length
might be downcasted wrongly when it's greater than type(uint8).max
. maxFeedsLength()
might pass the requirement when config.feeds
has many elements(like 256).
maxLength = uint8(Math.max(maxLength, feeds[i].length));
It deposits 0 amount to the staking contract to claim rewards but it might revert during the 0 transfer. There is no problem with the current lpToken
but good to keep in mind.
stakingContract.deposit(poolId, 0);
// oracleTimeout <= delta <= oracleTimeout + priceTimeout ==========> oracleTimeout < delta < oracleTimeout + priceTimeout
* - when `from` is zero, `amount` tokens will be minted for `to`. //@audit should remove, no hook if `from` or `to` is zero * - when `to` is zero, `amount` of ``from``'s tokens will be burned.
WrappedERC20
uses a permission mechanism and operators can have an infinite allowance once approved. It might be inconvenient/dangerous for some users.
function _deposit( address operator, address src, address dst, uint256 amount ) internal { if (!hasPermission(src, operator)) revert Unauthorized(); //@audit infinite allowance ... }
function _withdraw( address operator, address src, address dst, uint256 amount ) internal { if (!hasPermission(src, operator)) revert Unauthorized(); //@audit infinite allowance ... }
RewardableERC4626Vault
It's recommended to follow the instructions.
abstract contract RewardableERC4626Vault is ERC4626, RewardableERC20 {}
We don't need to accrue for src
because we don't use any information of src
and that info will be accrued during the underlyingComet transfer.
underlyingComet.accrueAccount(address(this)); underlyingComet.accrueAccount(src); //@audit needless accrue CometInterface.UserBasic memory wrappedBasic = underlyingComet.userBasic(address(this)); int104 wrapperPrePrinc = wrappedBasic.principal; IERC20(address(underlyingComet)).safeTransferFrom(src, address(this), amount);
_accumuatedRewards
=> _accumulatedRewards
uint256 _accumuatedRewards = accumulatedRewards[account];
#0 - c4-judge
2023-08-06T10:10:56Z
thereksfour marked the issue as grade-a
#1 - c4-judge
2023-08-06T10:11:00Z
thereksfour marked the issue as selected for report
#2 - thereksfour
2023-09-05T07:43:16Z
Move [L-08] to [N-02], it's no risk, more like a GAS issue. The rest is fine.