xanoutas/solidity-security-findings huggingface dataset:
Managed Wallet requires sending ETH to confirm wallets, but has no mechanism to withdraw the funds, leading to locked funds
Audit: 2024-01-salty-findings
Severity: HIGH

# Lines of code

https://github.com/code-423n4/2024-01-salty/blob/main/src/ManagedWallet.sol#L59


# Vulnerability details

## Description

The `ManagedWallet.sol` contract requires that the `confirmationWallet` send atleast `0.05 ether` to get a confirmation from the confirmation wallet to update the wallets.

But there are no mechanisms to withdraw the funds that were sent to the `ManagedWallet.sol`, and any ether that is send to the contract remains locked forever.

## Impact

The `ManagedWallet.sol` contract requires that funds are sent to the contract to confirm the wallet update. The funds are significant amount of atleast `0.05 ether` and there are no ways to pull the funds out of this contract. And so the funds remain locked forever in the contract.

## Proof of concept

Use the below test in the file: `src/root_tests/ManagedWallet.t.sol`

```solidity
function testNeoCraoPocFundsLocked() public {
    address initialMainWallet = address(0x1111);
    address initialConfirmationWallet = address(0x2222);

    address newMainWallet = address(0x3333);
    address newConfirmationWallet = address(0x4444);

    vm.deal(initialConfirmationWallet, 1 ether);

    ManagedWallet managedWallet = new ManagedWallet(initialMainWallet, initialConfirmationWallet);

    vm.startPrank(initialMainWallet);
    managedWallet.proposeWallets(newMainWallet, newConfirmationWallet);
    vm.stopPrank();

    uint256 sentValue = 0.06 ether;
    vm.prank(initialConfirmationWallet);
    (bool success,) = address(managedWallet).call{value: sentValue}("");
    assertTrue(success, "Confirmation of wallet proposal failed");

    uint256 currentTime = block.timestamp;
    vm.warp(currentTime + TIMELOCK_DURATION);

    vm.prank(newMainWallet);
    managedWallet.changeWallets();

    assertEq(managedWallet.mainWallet(), newMainWallet, "mainWallet was not updated correctly");
    assertEq(managedWallet.confirmationWallet(), newConfirmationWallet, "confirmationWallet was not updated correctly");

    assertEq(address(initialConfirmationWallet).balance, 1 ether - sentValue);
    assertEq(address(managedWallet).balance, sentValue);
}
```

## Severity Justification
This is High severity, based on the Code4rena Severity Categorization: https://docs.code4rena.com/awarding/judging-criteria/severity-categorization

3 — High: Assets can be stolen/lost/compromised directly (or indirectly if there is a valid attack path that does not have hand-wavy hypotheticals).

## Tools Used
Manual Review

## Recommended Mitigation Steps

There are a few options to solve this:
* Reduce the amount of funds required to send to this contract
* Sends the funds back to the confirmation wallet after the time lock is over


## Assessed type

ETH-Transfer
Collateral positions within cooldown period can not be liquidated, which may lead to an undercollateralized loans
Audit: 2024-01-salty-findings
Severity: HIGH

# Lines of code

https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/stable/CollateralAndLiquidity.sol#L154
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/staking/StakingRewards.sol#L107


# Vulnerability details

Adding liquidity and providing collateral to the salty.io protocol triggers a cooldown timer which prevents adding or removing new liquidity/collateral within one hour. This cooldown time can be adjusted by the DAO in the range of 15 min to 6 hours.

Collateralized loans of USDS have a `minimumCollateralRatioPercent` of 110% (adjustable by the DAO in the range from 110% - 120%).

An outside entity is able to call `CollateralAndLiquidity:liquidateUser` and liquidate collateral of users that fell below 110%. `liquidateUser` calls the function `StakingRewards:_decreaseUserShare` and checks if the users collateral cooldown time has expired. In case the users collateral cooldown time has not expired the function reverts and prevents the liquidation of a liquidatable position.

## Impact

In case of a market crash within the cooldown period loans may become undercollateralized due to the inability to liquidate these positions.

A malicious user could even prevent the liquidation of its position at all, in creating dust lp's with the min dust amounts of `PoolUtils.DUST`, every time the cooldown time expires.

## Proof of Concept
The following foundry test, copied in `CollateralAndLiquidity.t.sol` demonstrates that liquidatable position can not be liquidated while their collateral is within the cooldown period.

```
function testPositionCanNotBeLiquidatedInCooldown() public {
        // Bob deposits collateral
        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;
        // Alice deposits collateral
        collateralAndLiquidity.depositCollateralAndIncreaseShare(wbtcDeposit, wethDeposit, 0, block.timestamp, true);

        // Alice borrows USDS
        uint256 maxBorrowable = collateralAndLiquidity.maxBorrowableUSDS(alice);
        collateralAndLiquidity.borrowUSDS(maxBorrowable);
        vm.stopPrank();

        _crashCollateralPrice();

        // fast forward 50 min which is within the cooldown period currently set on 1 hour
        vm.warp(block.timestamp + 50 minutes);

        assertTrue(_userHasCollateral(alice));

        assertTrue(collateralAndLiquidity.canUserBeLiquidated(alice));

        // Reverts in StakingRewards:_decreaseUserShare which leads to user can not be liquidated in case cooldown period is not expired
        vm.expectRevert("Must wait for the cooldown to expire");
        collateralAndLiquidity.liquidateUser(alice);
    }
```

## Tools
lash loans can affect governance voting in Proposals.sol
Audit: 2024-01-salty-findings
Severity: HIGH

# Lines of code

https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L21


# Vulnerability details

## Impact
Flash loans can significantly increase a single voter's weight and be used to impact the voting outcome. A voter can borrow a significant quantity of tokens to increase their voting weight in a transaction within which they also deterministically influence the voting outcome to their choice.


## Tools Used
manual 

## Recommended Mitigation Steps
capping the weight of individual voters.




## Assessed type

Error
Liquidity._withdrawLiquidityAndClaim() should use try-catch block
Audit: 2024-01-salty-findings
Severity: HIGH

# Lines of code

https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/staking/Liquidity.sol#L121


# Vulnerability details

Impact:
In the function of Liquidity._withdrawLiquidityAndClaim(), removeLiquidity,safeTransfer(), _decreaseUserShare() would
 be inconsistent ,If  Liquidity._withdrawLiquidityAndClaim() haven't try-catch block

## Proof of Concept
Liquidity._withdrawLiquidityAndClaim() include three oprations:removeLiquidity,safeTransfer(), _decreaseUserShare().
We should add try-catch block to ensure consistency of the three oprations.

function _withdrawLiquidityAndClaim( IERC20 tokenA, IERC20 tokenB, uint256 liquidityToWithdraw, uint256 minReclaimedA, uint256 minReclaimedB ) internal returns (uint256 reclaimedA, uint256 reclaimedB)
		{
		bytes32 poolID = PoolUtils._poolID( tokenA, tokenB );
		require( userShareForPool(msg.sender, poolID) >= liquidityToWithdraw, "Cannot withdraw more than existing user share" );

		// Remove the amount of liquidity specified by the user.
		// The liquidity in the pool is currently owned by this contract. (external call)
		(reclaimedA, reclaimedB) = pools.removeLiquidity( tokenA, tokenB, liquidityToWithdraw, minReclaimedA, minReclaimedB, totalShares[poolID] );

		// Transfer the reclaimed tokens to the user
		tokenA.safeTransfer( msg.sender, reclaimedA );
		tokenB.safeTransfer( msg.sender, reclaimedB );

		_decreaseUserShare( msg.sender, poolID, liquidityToWithdraw, true );

		emit LiquidityWithdrawn(msg.sender, address(tokenA), address(tokenB), reclaimedA, reclaimedB, liquidityToWithdraw);
		}
		
## Tools Used
vscode  foundry

## Recommended Mitigation Steps
add try-catch block.


## Assessed type

Other
Upgraded Q -> 2 from #764 [1709117693673]
Audit: 2024-01-salty-findings
Severity: MEDIUM

Judge has assessed an item in Issue #764 as 2 risk. The relevant finding follows:

 Lines of code
https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L396-L397

Vulnerability details
Impact
Old proposals below the quorum can be executed in the future when the quorum is lowered. This may cause unwanted and unexpected effects on the ecosystem.

Proof of Concept
Users can create various proposals using _possiblyCreateProposal. Proposals can be executed and deleted with finalizeBallot. Initially, canFinalizeBallot is called to check if the proposal passed the quorum. If not, canFinalizeBallot returns false, the proposal remains active, regardless of the votes, and cannot be closed.

However, this can lead to an issue where, if the quorum is lowered, some proposals that didn’t get the required number of votes can be executed upon the lowering.

Example:

Assume quorum is at 20%.
Proposal to SEND_SALT to an investor’s address gets 60% YES and 40% NO but doesn’t pass the quorum threshold - 60% of total staked salt (requires 3x the quorum).
After a few months or a year, users decide to lower the quorum.
Vote after vote passes, lowering the quorum to 10%.
Now the old proposal to SEND_SALT returns true from canFinalizeBallot as it has enough votes.
A user executes the old proposal and sends the salt to the appointed address.
The address is no longer active, and the salt is stuck there.
This can be a common case, and it can happen with all kinds of proposals.

Tools Used
Manual review.

Recommended Mitigation Steps
Either set an expiration date for every proposal or modify the structure of finalizeBallot to include markBallotAsFinalized:

function finalizeBallot(uint256 ballotID) external nonReentrant {
-    require(proposals.canFinalizeBallot(ballotID), "The ballot is not yet able to be finalized");
    
+   if (proposals.canFinalizeBallot(ballotID)) {
        Ballot memory ballot = proposals.ballotForID(ballotID);

        if (ballot.ballotType == BallotType.PARAMETER) {
            _finalizeParameterBallot(ballotID);
        } else if (ballot.ballotType == BallotType.WHITELIST_TOKEN) {
            _finalizeTokenWhitelisting(ballotID);
        } else {
            _finalizeApprovalBallot(ballotID);
        }
+       proposals.markBallotAsFinalized(ballotID);
    }
}
Assessed type
Error

Lines of code
https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L396-L397

Vulnerability details
Impact
Old proposals below the quorum can be executed in the future when the quorum is lowered. This may cause unwanted and unexpected effects on the ecosystem.

Proof of Concept
Users can create various proposals using _possiblyCreateProposal. Proposals can be executed and deleted with finalizeBallot. Initially, canFinalizeBallot is called to check if the proposal passed the quorum. If not, canFinalizeBallot returns false, the proposal remains active, regardless of the votes, and cannot be closed.

However, this can lead to an
Upgraded Q -> 2 from #418 [1709115575632]
Audit: 2024-01-salty-findings
Severity: MEDIUM

Judge has assessed an item in Issue #418 as 2 risk. The relevant finding follows:

 When a pool gets unwhitelisted users are prevented from depositing into the depreceated pool and increasing their share.This is to prevent further trading/borrowing activities for that pool. However existing users that have already deposited in that pool without borrowing usds against their collateral or withdrawing can still borrow usds from the unwhitelisted pool with no risk of being liquidated as liquidations will fails in 1.unwhitelisted pools, and 2.freshly created pools.
Liquidations can fails for one core reason:

The pool lack enough liquidity to cover dust amount required to liquidate an user.
And this core reason can arise from various situations
The underlying pool were unwhitelisted so existing borrows repaid and withdrew their collaterals, liquidatable users were liquidated and now there is no activity for that pool so it remains only <= dust amounts in that pool.
The pool have just been created so first borrower position cannot be liquidated because their is not enough liquidity left to cover dust after liquidity removal.Note that this is only possible if he is the only borrower, after another borrower borrows , first borrower can borrow but the very last borrower position remaining to be liquidated will always fails to be liquidated.
I have wrote a PoC to simulate liquidating a position when pool doesn't own enough liquidity to cover dust after liquidity removal for both an active pool (first depositor) and an unwhitelisted pool that have <= dust amount left.
Upgraded Q -> 2 from #653 [1709062519468]
Audit: 2024-01-salty-findings
Severity: MEDIUM

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.
Upgraded Q -> 2 from #653 [1709062456905]
Audit: 2024-01-salty-findings
Severity: MEDIUM

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:

https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L98

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:

https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L385

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.
Upgraded Q -> 2 from #878 [1709061118939]
Audit: 2024-01-salty-findings
Severity: MEDIUM

Judge has assessed an item in Issue #878 as 2 risk. The relevant finding follows:

 [L-02] typo in Pools.removeLiquidity
File: https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/pools/Pools.sol#L187 In Pools.sol#L187, the currect check should be:

diff --git a/src/pools/Pools.sol b/src/pools/Pools.sol
index 7adb563..1fd3f3e 100644
--- a/src/pools/Pools.sol
+++ b/src/pools/Pools.sol
@@ -184,7 +184,7 @@ contract Pools is IPools, ReentrancyGuard, PoolStats, ArbitrageSearch, Ownable

                // Make sure that removing liquidity doesn't drive either of the reserves below DUST.
                // This is to ensure that ratios remain relatively constant even after a maximum withdrawal.
-        require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");
+        require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve1 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");

                // Switch reclaimed amounts back to the order that was specified in the call arguments so they make sense to the caller
                if (flipped)
Upgraded Q -> 2 from #878 [1709061114430]
Audit: 2024-01-salty-findings
Severity: MEDIUM

Judge has assessed an item in Issue #878 as 2 risk. The relevant finding follows:

 [L-05] CoreChainlinkFeed.MAX_ANSWER_DELAY is too strict
File: https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/price_feed/CoreChainlinkFeed.sol#L12 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/price_feed/CoreChainlinkFeed.sol#L44-L49 In CoreChainlinkFeed.latestChainlinkPrice, the function checkes if the answerDelay between two rounds is less than CoreChainlinkFeed.MAX_ANSWER_DELAY, if so, 0 is returns as price. But this check is too strict, take BTC/USD as example, for roundId 110680464442257320397, the updatedAt value is 1706586947, and for roundId 110680464442257320398, the updatedAt is 1706590607. So the delay between those two update time is 1706590607 - 1706586947 == 3660. In such cases, CoreChainlinkFeed.latestChainlinkPrice will return 0 if the tx is in front of chainlink’s price update tx. To fix the issue, we can change CoreChainlinkFeed.MAX_ANSWER_DELAY a little larger such as 65 mins
Upgraded Q -> 2 from #907 [1709054605297]
Audit: 2024-01-salty-findings
Severity: MEDIUM

Judge has assessed an item in Issue #907 as 2 risk. The relevant finding follows:

 [L-08] CoreChainlinkFeed uses the BTC_USD address when it should be using the WBTC-BTC one as well
CoreChainlinkFeed assumes that the price of BTC is equal to the price of WBTC.

This is the BTC/USD feed:

https://data.chain.link/feeds/ethereum/mainnet/btc-usd

This is the WBTC/BTC feed:

https://data.chain.link/feeds/ethereum/mainnet/wbtc-btc

Note that the WBTC/BTC is not 1:1. Users may not get the actual WBTC price (since protocol is using WBTC instead of BTC) which will affect collaterization ratio and future liquidations.

https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/CoreChainlinkFeed.sol#L32-L39
Upgraded Q -> 2 from #638 [1708536212549]
Audit: 2024-01-salty-findings
Severity: MEDIUM

Judge has assessed an item in Issue #638 as 2 risk. The relevant finding follows:

 L-8 - proposeWallets enters in a deadlock if the proposed wallet doesn’t call changeWallets()
The mainWallet has the ability to propose new wallets, which are tracked in storage.

Confirmation wallets have the ability to confirm or reject those proposed wallets. When there is a confirmation, an active timelock is set.

Once the timelock is over, the proposed wallet can call changeWallets() to make the changes for real.

The problem is that if the proposed wallet doesn’t call the function, or can’t do it, it’s not possible for the proposer to propose a new one, because of this requirement.

Recommendation
Allow the proposer to reset the timelock, and the proposed addresses if the proposed wallet didn’t call changeWallets() after some time.
Upgraded Q -> 2 from #762 [1708536089012]
Audit: 2024-01-salty-findings
Severity: MEDIUM

Judge has assessed an item in Issue #762 as 2 risk. The relevant finding follows:

 [L-6] Swaps in unwhitelisted pools can impact the accurate emission of rewards.

Let’s consider a scenario where the pools become unwhitelisted, and there are existing liquidities in them.
When swaps occur in those pools, arbitrage profits continue to be generated.
After performing upkeep, we have only cleared the whitelisted pools. Consequently, the _arbitrageProfits for the unwhitelisted pools become larger. https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/pools/PoolStats.sol#L51
function clearProfitsForPools() external {
    bytes32[] memory poolIDs = poolsConfig.whitelistedPools();
    for( uint256 i = 0; i < poolIDs.length; i++ )
	_arbitrageProfits[ poolIDs[i] ] = 0;
}
Once these pools become whitelisted again, their _arbitrageProfits will influence the subsequent rewards emission, and these pools will receive a significant portion of those rewards.
Upgraded Q -> 2 from #490 [1708429492922]
Audit: 2024-01-salty-findings
Severity: MEDIUM

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.
Upgraded Q -> 2 from #957 [1708275323727]
Audit: 2024-01-salty-findings
Severity: MEDIUM

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[](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)));
}
Upgraded Q -> 2 from #762 [1706966681157]
Audit: 2024-01-salty-findings
Severity: MEDIUM

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" );
}