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: 5/178
Findings: 13
Award: $2,551.96
🌟 Selected for report: 3
🚀 Solo Findings: 0
423.9623 USDC - $423.96
Staking in SALTY pools happens automatically when adding liquidity. In order to track the accrued rewards, the code "simulates" the amount of virtual rewards that need to be added given the increase of shares and lend this amount to the user. So, when computing the real rewards for a given user, the code will compute its rewards based on the totalRewards
of the given pool minus the virtual rewards. The following code computes the virtual rewards for a user:
uint256 virtualRewardsToAdd = Math.ceilDiv( totalRewards[poolID] * increaseShareAmount, existingTotalShares );
Basically, it aims to maintain the current ratio of totalRewards
and existingTotalShares
. The issue with this is that allows the first depositor to set the ratio too high by donating some SALT tokens to the contract. For example, consider the following values:
uint256 virtualRewardsToAdd = Math.ceilDiv( 1000e18 * 200e18, 202 );
The returned value is in order of 39-40 digits. Which is beyond what 128 bits can represent:
user.virtualRewards += uint128(virtualRewardsToAdd); totalRewards[poolID] += uint128(virtualRewardsToAdd);
This will broke the reward computations. For a more concrete example look the PoC.
The following coded PoC showcase an scenario where the first depositor set the rewards / shares
ratio too high, causing the rewards system to get broken. Specifically, it shows how the sum of the claimable rewards for each user is greater than the SALT balance of the contract.
It should be pasted under Staking/tests/StakingRewards.t.sol
.
function testUserCanBrickRewards() public { vm.startPrank(DEPLOYER); // Alice is the first depositor to poolIDs[1] and she deposited the minimum amounts 101 and 101 of both tokens. // Hence, alice will get 202 shares. stakingRewards.externalIncreaseUserShare(alice, poolIDs[1], 202, true); assertEq(stakingRewards.userShareForPool(alice, poolIDs[1]), 202); vm.stopPrank(); // Alice adds 100 SALT as rewards to the pool. AddedReward[] memory addedRewards = new AddedReward[](1); addedRewards[0] = AddedReward(poolIDs[1], 100 ether); stakingRewards.addSALTRewards(addedRewards); // Bob deposits 100 DAI and 100 USDS he will receive (202 * 100e8) / 101 = 200e18 shares. vm.startPrank(DEPLOYER); stakingRewards.externalIncreaseUserShare(bob, poolIDs[1], 200e18, true); assertEq(stakingRewards.userShareForPool(bob, poolIDs[1]), 200e18); vm.stopPrank(); // Charlie deposits 10000 DAI and 10000 USDS he will receive (202 * 10000e8) / 101 = 20000e18 shares. vm.startPrank(DEPLOYER); stakingRewards.externalIncreaseUserShare(charlie, poolIDs[1], 20000e18, true); assertEq(stakingRewards.userShareForPool(charlie, poolIDs[1]), 20000e18); vm.stopPrank(); // Observe how virtual rewards are broken. uint256 virtualRewardsAlice = stakingRewards.userVirtualRewardsForPool(alice, poolIDs[1]); uint256 virtualRewardsBob = stakingRewards.userVirtualRewardsForPool(bob, poolIDs[1]); uint256 virtualRewardsCharlie = stakingRewards.userVirtualRewardsForPool(charlie, poolIDs[1]); console.log("Alice virtual rewards %s", virtualRewardsAlice); console.log("Bob virtual rewards %s", virtualRewardsBob); console.log("Charlie virtual rewards %s", virtualRewardsCharlie); // Observe the amount of claimable rewards. uint256 aliceRewardAfter = stakingRewards.userRewardForPool(alice, poolIDs[1]); uint256 bobRewardAfter = stakingRewards.userRewardForPool(bob, poolIDs[1]); uint256 charlieRewardAfter = stakingRewards.userRewardForPool(charlie, poolIDs[1]); console.log("Alice rewards %s", aliceRewardAfter); console.log("Bob rewards %s", bobRewardAfter); console.log("Charlie rewards %s", charlieRewardAfter); // The sum of claimable rewards is greater than 1000e18 SALT. uint256 sumOfRewards = aliceRewardAfter + bobRewardAfter + charlieRewardAfter; console.log("All rewards &s", sumOfRewards); bytes32[] memory poolIDs2; poolIDs2 = new bytes32[](1); poolIDs2[0] = poolIDs[1]; // It reverts vm.expectRevert("ERC20: transfer amount exceeds balance"); vm.startPrank(charlie); stakingRewards.claimAllRewards(poolIDs2); vm.stopPrank(); }
Manual Review
Some options:
Make the function addRewards
in the StakingRewards
contract permissioned. In this way, all rewards will need to go through the emitter first.
Do not let the first depositor to manipulate the initial ratio of rewards / share. It is possible for every pool to burn the initial 10000 shares and starts with an initial small amount of rewards, kind of simulating being the first depositor.
Other
#0 - c4-sponsor
2024-02-08T09:32:43Z
othernet-global (sponsor) confirmed
#1 - othernet-global
2024-02-15T03:08:20Z
virtualRewards and userShare are now uint256 rather than uint128.
Fixed in: https://github.com/othernet-global/salty-io/commit/5f79dc4f0db978202ab7da464b09bf08374ec618
#2 - Picodes
2024-02-18T16:52:48Z
Considering that you could time this to break in the future and that it seems easily doable by an attacker on a new pool, High severity seems justified under "Loss of matured yield".
#3 - c4-judge
2024-02-18T16:53:00Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2024-02-18T16:53:03Z
Picodes marked the issue as selected for report
🌟 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
Any user can avoid liquidation by incrementing its cooldownExpiration
.
During liquidations the logic of the liquidateUser
function will call _decreaseUserShare
for the liquidated user. The issue is that it sets the flag for the cooldown check to true
:
If the flag is set to true
, then the code will validate that block.timestamp > cooldownExperation
.
Because of this, the user could add the minimum amount of liquidity to the WETH / WBTC pool, incrementing his coolDownExpiration
and avoid any liquidation even if its position is still unhealthy.
A coded PoC is provided, paste it under the tests/CollateralAndLiquidity.t.sol
file:
function testUserCanAvoidLiquidation() public { // Alice deposits 100 WBTC and 100 WETH and borrows the maximum usds possible. vm.startPrank( alice ); collateralAndLiquidity.depositCollateralAndIncreaseShare(100e8, 100e18, 0, block.timestamp, false ); uint256 maxUSDS = collateralAndLiquidity.maxBorrowableUSDS(alice); assertFalse(maxUSDS == 0); collateralAndLiquidity.borrowUSDS( maxUSDS ); vm.stopPrank(); // Alice cannot be liquidated assertFalse(collateralAndLiquidity.canUserBeLiquidated(alice)); vm.warp( block.timestamp + 1 days ); // Alice adds more collateral before the crash of the price. // @audit With this it resets its cooldown period. vm.startPrank( alice ); collateralAndLiquidity.depositCollateralAndIncreaseShare(101, 101e10, 0, block.timestamp, false); vm.stopPrank(); // Artificially crash the collateral price _crashCollateralPrice(); // Make bob to do a deposit. vm.startPrank( bob ); collateralAndLiquidity.depositCollateralAndIncreaseShare(100e8, 100e18, 0, block.timestamp, false ); vm.stopPrank(); // Alice account is liquiditable assertTrue(collateralAndLiquidity.canUserBeLiquidated(alice)); // Liquidation does not go through due to cooldown period. vm.expectRevert( "Must wait for the cooldown to expire" ); vm.prank(bob); collateralAndLiquidity.liquidateUser(alice); }
Manual Review
Set the cooldown flag to false
instead of true
.
DoS
#0 - c4-judge
2024-01-31T22:46:09Z
Picodes marked the issue as duplicate of #891
#1 - c4-judge
2024-02-21T16:21:38Z
Picodes marked the issue as satisfactory
#2 - thebrittfactor
2024-02-21T21:56:44Z
For transparency, the judge confirmed issue should be marked as duplicate-312
.
16.3165 USDC - $16.32
Malicious users can prevent the DAO from accruing POL (salt/usds and dai/usds) and to lose the SALT withdrawn tokens from POL.
When a liquidation happens, the CollateralAndLiquidity
contract sends the recently liquidated WETH
and WBTC
to the Liquidizer
contract and also makes it to increment it's usdsThatShouldBeBurned
value by an amount equal to the recently liquidated debt.
The idea is that the Liquidizer
contract will sell DAI
, WETH
and WBTC
for USDS
and then it will burn an amount equal to usdsThatShouldBeBurned
. Basically, this is the mechanism to which the system takes out of circulation the liquidated debt's USDS.
An important note is that when the Liquidizer
has less USDS
balance than the value of usdsThatShouldBeBurned
it will burn what it have and then will call dao.withdrawPOL
for USDS / SALT
and DAI / USDS
. The SALT withdrawn will get lost (design decision) to no put sell pressure on USDS.
The security vulnerability emerges due to a logic error in the repayUSDS
function. This flaw allows any user to exert pressure on the Liquidizer
contract, leading to constant POL withdrawals. In the mentioned function we can see the following:
// @audit It increments the amount of USDS to burn in Liquidizer but the transfer // of USDS goes to the USDS contract. usds.safeTransferFrom(msg.sender, address(usds), amountRepaid); liquidizer.incrementBurnableUSDS( amountRepaid );
We can see that the logic makes Liquidizer to increment the usdsThatShouldBeBurned
value, but its USDS balance wont increment. Malicious users can exacerbate the problem by making loops of borrowing, which is free, and repayments.
Manual Review
Two options:
Instead of transferring the USDS to the USDS contract, they should be transferred to the liquidizer contract.
Do not call liquidizer.incrementBurnableUSDS
.
Other
#0 - c4-judge
2024-02-01T15:50:29Z
Picodes marked the issue as duplicate of #618
#1 - c4-judge
2024-02-17T18:38:18Z
Picodes marked the issue as satisfactory
🌟 Selected for report: vnavascues
372.7994 USDC - $372.80
User may not be able to create another proposal for a long time even if its token whitelisting proposal is accepted by the community.
Users can propose a token whitelisting ballot via the proposeTokenWhitelisting
function. Where the code makes the following validation:
require( poolsConfig.numberOfWhitelistedPools() < poolsConfig.maximumWhitelistedPools(), "Maximum number of whitelisted pools already reached" );
The issue is that when a token is whitelisted, the poolsConfig
contract actually whitelist two pools, one with weth
and the other with wbtc
. Hence, when the whitelisted pool array is at its maximum capacity minus one, the proposal wont go through even if its the most voted on the array of pending whitelistings, due to the validation on poolsConfig
:
This will cause the creator an indefinite DoS because its proposal cannot be finalized unless people change their votes.
The maximum number of whitelisted pools is 100. The number of initial whitelisted pools is 9:
It is possible to reach the state where there are 99 whitelisted pools. And the proposeTokenWhitelisting
function will allow to create another whitelisting proposal.
Manual Review
The correct validation should be:
require( poolsConfig.numberOfWhitelistedPools() + 1 < poolsConfig.maximumWhitelistedPools(), "Maximum number of whitelisted pools already reached" );
DoS
#0 - c4-judge
2024-02-20T11:44:13Z
Picodes marked the issue as not a duplicate
#1 - c4-judge
2024-02-20T11:46:49Z
Picodes marked the issue as duplicate of #1009
#2 - c4-judge
2024-02-20T11:47:04Z
Picodes marked the issue as not a duplicate
#3 - c4-judge
2024-02-20T11:47:23Z
Picodes marked the issue as duplicate of #1009
#4 - Picodes
2024-02-20T11:47:37Z
This report shows one possible scenario leading to #1009
#5 - c4-judge
2024-02-21T16:55:58Z
Picodes marked the issue as satisfactory
53.4926 USDC - $53.49
When liquidating a user, the logic of the function liquidateUser
removes all the liquidity of the liquidated account.
The issue arises when the user been liquidated is also the last liquidity provider in the pool. This is because when removing liquidity there is a requirement for a minimum amount of reserves. Hence, the liquidation will not go through.
A coded PoC is provided, paste it under the tests/CollateralAndLiquidity.t.sol
file:
function testLastLiquidityProviderCannotBeLiquidiated() public { // Alice deposits 100 WBTC and 100 WETH and borrows the maximum usds possible. vm.startPrank( alice ); collateralAndLiquidity.depositCollateralAndIncreaseShare(100e8, 100e18, 0, block.timestamp, false ); uint256 maxUSDS = collateralAndLiquidity.maxBorrowableUSDS(alice); assertFalse(maxUSDS == 0); collateralAndLiquidity.borrowUSDS( maxUSDS ); vm.stopPrank(); // Alice cannot be liquidated assertFalse(collateralAndLiquidity.canUserBeLiquidated(alice)); vm.warp( block.timestamp + 1 days ); // Artificially crash the collateral price _crashCollateralPrice(); // Alice account is liquiditable assertTrue(collateralAndLiquidity.canUserBeLiquidated(alice)); // Liquidation does not go through due reserves limitation. vm.expectRevert( "Insufficient reserves after liquidity removal" ); vm.prank(bob); collateralAndLiquidity.liquidateUser(alice); }
Manual Review
In the event of a liquidation allow it go through even if the reserves become less than dust.
Other
#0 - c4-judge
2024-02-01T22:41:05Z
Picodes marked the issue as primary issue
#1 - c4-judge
2024-02-01T23:02:10Z
Picodes marked issue #459 as primary and marked this issue as a duplicate of 459
#2 - c4-judge
2024-02-21T08:13:39Z
Picodes marked the issue as satisfactory
8.7582 USDC - $8.76
The ManagedWallet contract has 4 accounts mainWallet
, confirmationWallet
, proposedMainWallet
and proposedConfirmationWallet
. The mainWallet
is used in the Salty protocol as the recipient of the accrued rewards for the team.
The mainWallet
can select another address to be the new mainWallet
. But, to avoid any mistake on this change, the contract implements a two-step transfership of this role. For this purpose the confirmationWallet
is used.
The issue is that confirmationWallet
is forced to either accept the new proposedMainWallet
or the contract will stay in the same state without having the opportunity to change the mainWallet
.
Once the mainWallet
has proposed a proposedMainWallet
then it cannot change it:
So, in case an incorrect account is proposed as the new main wallet, the confirmation wallet will reject it, but given that the mainWallet
cannot change the proposed wallet, then the contract will never change its state and the business logic of been able to change the mainWallet
will get broken.
Manual Review
After some time, the mainWallet
should be able to change proposedMainWallet
.
DoS
#0 - c4-judge
2024-02-02T10:42:57Z
Picodes marked the issue as duplicate of #838
#1 - c4-judge
2024-02-17T18:22:31Z
Picodes marked the issue as satisfactory
🌟 Selected for report: neocrao
Also found by: 00xSEV, 0x11singh99, 0x3b, 0xAlix2, 0xRobocop, 0xSmartContractSamurai, 0xanmol, AgileJune, Drynooo, HALITUS, Imp, J4X, KHOROAMU, Kalyan-Singh, MSaptarshi, RootKit0xCE, The-Seraphs, agadzhalov, aman, ayden, cu5t0mpeo, erosjohn, ewah, jasonxiale, jesjupyter, juancito, klau5, memforvik, okolicodes, parrotAudits0, rudolph, t0x1c, wangxx2026, zhaojohnson
1.6255 USDC - $1.63
The pool
contract works on the assumption that the reserves are greater than a declared DUST amount. This is enforced in every operation involving the movement of liquidity reserves.
Notably, in the removeLiquidity
function, the logic validates that the final amount of reserves is not below the declared dust amounts. The issue is that it does that incorrectly, see:
require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");
It just validates that reserve0
is greater than DUST, but not for reserves1
.
Link to the incorrect validation:
Manual Review
Change one of the reserves0
to reserves1
.
Invalid Validation
#0 - c4-judge
2024-02-01T23:05:38Z
Picodes marked the issue as duplicate of #647
#1 - c4-judge
2024-02-19T15:44:16Z
Picodes marked the issue as satisfactory
🌟 Selected for report: 0xpiken
Also found by: 0xBinChook, 0xRobocop, 0xWaitress, Aymen0909, Draiakoo, Infect3d, J4X, Toshii, cats, haxatron, jasonxiale, klau5, solmaxis69, t0x1c, zhaojie
26.3224 USDC - $26.32
The quorum needed for proposals to go through is computed based on a percentage of the current xSALT staked:
Given that this value is dynamic, in other words, it depends on the current amount of xSALT staked at the moment when the ballot wants to be finalized. Then, it allows users to game the accounting of the quorum by casting their vote and then unstaking.
To make the example clear, consider the following scenario:
Then, a given user has 100 xSALT balance. The user can cast his vote and then unstake, then:
This new state allows the user to reach the quorum for its proposal. Which is unfair because the casted votes are still counting as amount of votes but they do not contribute to the computed final quorum.
Manual Review
During the creation of a ballot the needed quorum should be saved within the ballot information. When someone wants to finalize the ballot, the saved value can be compared with the current needed quorum and the code can choose the greatest of them. For example, in the above example the next would have happened (pseduo-solidity):
uint256 currentQuorum = requiredQuorumForBallotType(ballot); uint256 neededQuorum = max(currentQuourum, savedQuorum); // neededQuorum will be 125 instead of 100
It is also important to consider a mechanism to finalize ballots after a period of time even if they did not reach the quorum.
Governance
#0 - c4-judge
2024-02-21T14:26:13Z
Picodes marked the issue as satisfactory
#1 - 0xRobocop
2024-02-23T05:58:39Z
I think there was a bug with the judge extension here, it seems that this issues did not get correctly duplicated.
#2 - Picodes
2024-02-27T19:31:53Z
@0xRobocop sorry I don't see the issue here?
🌟 Selected for report: juancito
Also found by: 0xCiphky, 0xRobocop, 0xWaitress, DanielArmstrong, J4X, PENGUN, erosjohn, haxatron, klau5, lanrebayode77, pina, twcctop, zhaojie
37.1392 USDC - $37.14
Judge has assessed an item in Issue #653 as 2 risk. The relevant finding follows:
L-03 Some DAO proposals can be hard to propose. Some proposals such as proposeCountryInclusion, proposeCountryExclusion, proposeSetContractAddress and proposeWebsiteUpdate can only be created once. For example, in other words, proposing a ballot for changing the address for priceFeed_1 is only possible if there are not active ballots for the same purpose.
So, this allows a griefing attack vector where a well-funded user may try to always spam the creation of these proposals with a dummy address after the finalization of the previous one. This can be a challenge for governance participants, since they first need to coordinate efforts to reach the quorum in order to finalize the active dummy proposal and then ensure that they can propose the valid one previous in the block than the attacker.
Recommendation:
Information can included for the identification of the ballot in order to discriminate them even if they are the same type of proposal. For example, in the case of the price feeds, the proposed new address can be used to differentiate between the attacker one and the valid one.
#0 - c4-judge
2024-02-27T19:35:29Z
Picodes marked the issue as satisfactory
#1 - c4-judge
2024-02-27T19:35:43Z
Picodes marked the issue as duplicate of #621
53.4926 USDC - $53.49
Judge has assessed an item in Issue #653 as 2 risk. The relevant finding follows:
L-04 Reaching quorum to finalize a ballot is a too hard requirement. The current logic on making proposals only allow a user to have at most one active proposal:
The issue is that in order to finalize a proposal even if it was not approved it need to reach a certain quorum of votes:
This is a too hard requirement, since it makes the user dependent on the participation that its proposal can get regardless if the direction is positive or negative. Some proposals encourage the community to vote (because they can only be proposed once, like change a parameter), but other ones such as proposeCallContract do not.
Users can still unstake and migrate their tokens to another account, but this will take almost a year in order to unstake completely.
Recommendation:
To avoid a DoS state on the proposals made by users due to a small activity of voters, it should be possible to finalize non-reached quorum ballots after some period of time.
#0 - c4-judge
2024-02-27T19:34:32Z
Picodes marked the issue as satisfactory
#1 - c4-judge
2024-02-27T19:34:43Z
Picodes marked the issue as duplicate of #362
1196.6399 USDC - $1,196.64
The rewards earned from the DAOs POL are distributed among the team wallet, then part of the remaining rewards are burned and the rest are kept as DAOs balance.
The issue, is that is possible for the DAO to claim SALT rewards without sending the team's share to the team wallet and without burning the amount that should be burned.
The first step during the upkeep
functionality is to perform the upkeep on the liquidizer
contract.
During that, if the amount of USDS to be burned is greater than the current balance of the liquidizer
contract, then the code will withdraw some POL from the usds/dai and usds/salt pools:
When removing liquidity, the code will decrease the dao's share and in doing so it will send to it some rewards proportional to the amount of shares decreased:
It is important note that pool rewards do not necessary comes from the emitter, but they can be added by third party protocols via the addRewards
function in the StakingRewards
contract:
Manual Review
Two options:
Save the amount of rewards received when withdrawing some POL, so they can be distributed and burned.
Make the call to claim all the rewards at the beginning of upkeep
.
Other
#0 - c4-judge
2024-02-07T17:53:03Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2024-02-08T09:05:21Z
othernet-global (sponsor) acknowledged
#2 - Picodes
2024-02-21T13:46:15Z
This report shows how in some cases some rewards may end up being stuck when withdrawing PoL.
#3 - c4-judge
2024-02-21T13:46:23Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2024-02-21T16:48:27Z
Picodes marked the issue as selected for report
#5 - othernet-global
2024-02-23T03:45:07Z
POL has been removed from the protocol
eaf40ef0fa27314c6e674db6830990df68e5d70e https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed
🌟 Selected for report: 0xRobocop
Also found by: DanielArmstrong, KupiaSec, deepplus, oakcobalt
348.9402 USDC - $348.94
The DAO contract cannot handle any token beside SALT tokens. So, if tokens like USDS or DAI were in its balance, they will be lost forever.
This can happen during the upkeep
calls. Basically, during upkeep the contract takes some percentage from the arbitrage profits and use them to form POL for the DAO (usds/dai and salt/usds). The DAO swaps the ETH for both of the needed tokens and then adds the liquidity using the zapping flag to true.
Zapping will compute the amount of either tokenA or tokenB to swap in order to add liquidity at the final ratio of reserves after the swap. But, it is important to note that the zap computations do no take into account that the same pool may get arbitraged atomically, changing the ratio of reserves a little.
As a consequence, some of the USDS and DAI tokens will be send back to the DAO contract:
The following coded PoC should be pasted into root_tests/Upkeep.t.sol
, actually its the same test that can be found at the end of the file with some added lines to showcase the issue. Specifically, it shows how the USDS and DAI balance of the DAO are zero before upkeep and how both are greater than zero after upkeep.
function testDoublePerformUpkeep() public { _setupLiquidity(); _generateArbitrageProfits(false); // Dummy WBTC and WETH to send to Liquidizer vm.prank(DEPLOYER); weth.transfer( address(liquidizer), 50 ether ); // Indicate that some USDS should be burned vm.prank( address(collateralAndLiquidity)); liquidizer.incrementBurnableUSDS( 40 ether); // Mimic arbitrage profits deposited as WETH for the DAO vm.prank(DEPLOYER); weth.transfer(address(dao), 100 ether); vm.startPrank(address(dao)); weth.approve(address(pools), 100 ether); pools.deposit(weth, 100 ether); vm.stopPrank(); // === Perform upkeep === address upkeepCaller = address(0x9999); uint256 daiDaoBalanceBefore = dai.balanceOf(address(dao)); uint256 usdsDaoBalanceBefore = usds.balanceOf(address(dao)); assertEq(daiDaoBalanceBefore, 0); assertEq(usdsDaoBalanceBefore, 0); vm.prank(upkeepCaller); upkeep.performUpkeep(); // ================== _secondPerformUpkeep(); uint256 daiDaoBalanceAfter = dai.balanceOf(address(dao)); uint256 usdsDaoBalanceAfter = usds.balanceOf(address(dao)); assertTrue(daiDaoBalanceAfter > 0); assertTrue(usdsDaoBalanceAfter > 0); }
Manual Review
The leftovers of USDS or DAI should be send to liquidizer so they can be handled.
Other
#0 - c4-judge
2024-02-02T10:31:02Z
Picodes marked the issue as duplicate of #721
#1 - c4-judge
2024-02-17T18:53:48Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-02-17T18:54:47Z
Picodes marked the issue as selected for report
#3 - c4-sponsor
2024-02-17T22:44:58Z
othernet-global (sponsor) confirmed
#4 - othernet-global
2024-02-17T23:21:06Z
The DAO contract uses the available token balances to form POL, ensuring no extra tokens left in the contract.
https://github.com/othernet-global/salty-io/commit/5364426aaf97e646fa3990f148e364167adcd0a5
#5 - othernet-global
2024-02-23T03:45:16Z
POL has been removed from the protocol
eaf40ef0fa27314c6e674db6830990df68e5d70e https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed
🌟 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
11.6897 USDC - $11.69
When finalizeBallot
is called and the majority voted yes, then the code initiates the distribution of SALT tokens calling the distributionApproved
function of the InitialDistribution
contract which should have been transferred the 100M SALT tokens previously.
The issue arises because users can still cast their vote even after the ballot completion time has passed. So it is possible for users to vote negative and outnumber the "yes" votes:
If SALT supply were sent non-atomically with the call to finalizeBallot
it will cause the SALT tokens to get stucked in the InitialDistribution
contract:
Recommendations:
block.timestamp > completion
.finalizeBallot
atomically.The initial distribution of SALT tokens take into account an initial airdrop where whitelisted users that casted their votes can claim their SALT rewards. The issue is that in case some users do not claim these SALT tokens, there is no other way to take this tokens out of the contract.
Recommendation: Send the remaining SALT balance from the airdrop contract to the DAO after a specified deadline.
Some proposals such as proposeCountryInclusion
, proposeCountryExclusion
, proposeSetContractAddress
and proposeWebsiteUpdate
can only be created once. For example, in other words, proposing a ballot for changing the address for priceFeed_1 is only possible if there are not active ballots for the same purpose.
So, this allows a griefing attack vector where a well-funded user may try to always spam the creation of these proposals with a dummy address after the finalization of the previous one. This can be a challenge for governance participants, since they first need to coordinate efforts to reach the quorum in order to finalize the active dummy proposal and then ensure that they can propose the valid one previous in the block than the attacker.
Recommendation:
The current logic on making proposals only allow a user to have at most one active proposal:
The issue is that in order to finalize a proposal even if it was not approved it need to reach a certain quorum of votes:
This is a too hard requirement, since it makes the user dependent on the participation that its proposal can get regardless if the direction is positive or negative. Some proposals encourage the community to vote (because they can only be proposed once, like change a parameter), but other ones such as proposeCallContract
do not.
Users can still unstake and migrate their tokens to another account, but this will take almost a year in order to unstake completely.
Recommendation:
#0 - c4-judge
2024-02-03T13:53:34Z
Picodes marked the issue as grade-a
#1 - c4-judge
2024-02-07T18:10:07Z
Picodes marked the issue as grade-b
#2 - 0xRobocop
2024-02-23T06:00:56Z
L-03 discusses the same issue reported in #621 L-04 discusses the same issue reported in #362