Platform: Code4rena
Start Date: 31/10/2023
Pot Size: $60,500 USDC
Total HM: 9
Participants: 65
Period: 10 days
Judge: gzeon
Total Solo HM: 2
Id: 301
League: ETH
Rank: 9/65
Findings: 3
Award: $884.91
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: TresDelinquentes
716.7564 USDC - $716.76
https://github.com/code-423n4/2023-10-party/blob/main/contracts/party/PartyGovernanceNFT.sol#L344-L448 https://github.com/code-423n4/2023-10-party/blob/main/contracts/party/PartyGovernanceNFT.sol#L263-L305
The issue will result in a significant loss of token amounts for users who attempt to execute the rageQuit()
function in the PartyGovernanaceNFT
contract with non native withdrawTokens
.
In the PartyGovernanaceNFT.sol
contract, the rageQuit()
function is designed to allow users to withdraw both their voting power (Governance NFTs) and assets (Ether or other fungible tokens).
As parameters the rageQuit()
function get:
Name | Type | Description |
---|---|---|
tokenIds | uint256[] | The IDs of the Party Cards to burn. |
withdrawTokens | IERC20[] | The fungible tokens to withdraw. Specify the ETH_ADDRESS value to withdraw ETH. |
minWithdrawAmounts | uint256[] | The minimum amount of to withdraw for each token. |
receiver | address | The address to receive the withdrawn tokens. |
However, there is a critical oversight in the implementation: it does not check if the balance (withdrawTokens[i].balanceOf(address(this))
) is zero before proceeding with the withdrawal, if the current withdrawToken
is ERC20.
As a result, in a scenario where a user attempts to execute rageQuit()
to withdraw ERC20 tokens, and the contract's ERC20 balance is zero, the user will not receive any ERC20 token amounts back, because the code does not check whether the contract holds any of this ERC20 to distribute, but the user's Governance NFTs (voting rights) will be burned as expected and user cannot again call rageQuit()
function.
rageQuit()
function to exit his position and obtain his share of the tokens held by the party contract. Bob set as withdrawToken
some ERC20 token (DAI for example).As a result, Bob experiences a loss of their Governance NFTs without receiving the intended DAI.
function rageQuit( uint256[] calldata tokenIds, IERC20[] calldata withdrawTokens, uint256[] calldata minWithdrawAmounts, address receiver ) external { /// ...code... // Sum up total amount of each token to withdraw. uint256[] memory withdrawAmounts = new uint256[](withdrawTokens.length); { IERC20 prevToken; for (uint256 i; i < withdrawTokens.length; ++i) { // Check if order of tokens to transfer is valid. // Prevent null and duplicate transfers. if (prevToken >= withdrawTokens[i]) revert InvalidTokenOrderError(); prevToken = withdrawTokens[i]; // Check token's balance. uint256 balance = address(withdrawTokens[i]) == ETH_ADDRESS ? address(this).balance : withdrawTokens[i].balanceOf(address(this)); // @audit balance can be zero // Add fair share of tokens from the party to total. for (uint256 j; j < tokenIds.length; ++j) { // Must be retrieved before burning the token. withdrawAmounts[i] += (balance * getVotingPowerShareOf(tokenIds[j])) / 1e18; // @audit `withdrawAmount` will be zero } } } { // Burn caller's party cards. This will revert if caller is not the // the owner or approved for any of the card they are attempting to // burn, not an authority, or if there are duplicate token IDs. uint96 totalVotingPowerBurned = _burnAndUpdateVotingPower(tokenIds, !isAuthority_); // @audit the user Governance NFTs amount is burned // Update total voting power of party. _getSharedProposalStorage().governanceValues.totalVotingPower -= totalVotingPowerBurned; } { uint16 feeBps_ = feeBps; for (uint256 i; i < withdrawTokens.length; ++i) { IERC20 token = withdrawTokens[i]; uint256 amount = withdrawAmounts[i]; // Take fee from amount. uint256 fee = (amount * feeBps_) / 1e4; if (fee > 0) { amount -= fee; // Transfer fee to fee recipient. if (address(token) == ETH_ADDRESS) { payable(feeRecipient).transferEth(fee); } else { token.compatTransfer(feeRecipient, fee); } } if (amount > 0) { uint256 minAmount = minWithdrawAmounts[i]; // Check amount is at least minimum. if (amount < minAmount) { revert BelowMinWithdrawAmountError(amount, minAmount); } // Transfer token from party to recipient. if (address(token) == ETH_ADDRESS) { payable(receiver).transferEth(amount); } else { token.compatTransfer(receiver, amount); } } } } /// ...code... }
Add a Check for Non-Zero Ether Balance: In the rageQuit()
function, before proceeding with the withdrawal of ERC20token, add a check to ensure that the contract holds a non-zero balance of this ERC20 token. If the balance is zero, revert the transaction to prevent the burning of Governance NFTs without a ERC20 Ether withdrawal.
📁 File PartyGovernanceNFT.sol + error ZeroBalance(); function rageQuit( uint256[] calldata tokenIds, IERC20[] calldata withdrawTokens, uint256[] calldata minWithdrawAmounts, address receiver ) external { /// ...code... // Sum up total amount of each token to withdraw. uint256[] memory withdrawAmounts = new uint256[](withdrawTokens.length); { IERC20 prevToken; for (uint256 i; i < withdrawTokens.length; ++i) { // Check if order of tokens to transfer is valid. // Prevent null and duplicate transfers. if (prevToken >= withdrawTokens[i]) revert InvalidTokenOrderError(); prevToken = withdrawTokens[i]; // Check token's balance. uint256 balance = address(withdrawTokens[i]) == ETH_ADDRESS ? address(this).balance : withdrawTokens[i].balanceOf(address(this)); // @audit balance can be zero + if (balance == 0) { + revert ZeroBalance(); + } // Add fair share of tokens from the party to total. for (uint256 j; j < tokenIds.length; ++j) { // Must be retrieved before burning the token. withdrawAmounts[i] += (balance * getVotingPowerShareOf(tokenIds[j])) / 1e18; // @audit `withdrawAmount` will be zero } } } { // Burn caller's party cards. This will revert if caller is not the // the owner or approved for any of the card they are attempting to // burn, not an authority, or if there are duplicate token IDs. uint96 totalVotingPowerBurned = _burnAndUpdateVotingPower(tokenIds, !isAuthority_); // @audit the user Governance NFTs amount is burned // Update total voting power of party. _getSharedProposalStorage().governanceValues.totalVotingPower -= totalVotingPowerBurned; } { uint16 feeBps_ = feeBps; for (uint256 i; i < withdrawTokens.length; ++i) { IERC20 token = withdrawTokens[i]; uint256 amount = withdrawAmounts[i]; // Take fee from amount. uint256 fee = (amount * feeBps_) / 1e4; if (fee > 0) { amount -= fee; // Transfer fee to fee recipient. if (address(token) == ETH_ADDRESS) { payable(feeRecipient).transferEth(fee); } else { token.compatTransfer(feeRecipient, fee); } } if (amount > 0) { uint256 minAmount = minWithdrawAmounts[i]; // Check amount is at least minimum. if (amount < minAmount) { revert BelowMinWithdrawAmountError(amount, minAmount); } // Transfer token from party to recipient. if (address(token) == ETH_ADDRESS) { payable(receiver).transferEth(amount); } else { token.compatTransfer(receiver, amount); } } } } /// ...code... }
Error
#0 - ydspa
2023-11-12T12:22:28Z
QA: L
#1 - c4-pre-sort
2023-11-12T12:22:34Z
ydspa marked the issue as insufficient quality report
#2 - c4-judge
2023-11-19T15:53:08Z
gzeon-c4 marked the issue as duplicate of #469
#3 - c4-judge
2023-11-19T15:53:14Z
gzeon-c4 changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-11-19T15:53:19Z
gzeon-c4 marked the issue as satisfactory
🌟 Selected for report: TresDelinquentes
Also found by: 3docSec, Arz, Bauchibred, D1r3Wolf, J4X, Neon2835, Pechenite, adeolu, chainsnake
152.3655 USDC - $152.37
Judge has assessed an item in Issue #304 as 2 risk. The relevant finding follows:
Incorrect minTotalContribution and minContribution Interaction
#0 - c4-judge
2023-11-26T17:02:43Z
gzeon-c4 marked the issue as duplicate of #127
#1 - c4-judge
2023-11-26T17:02:48Z
gzeon-c4 marked the issue as satisfactory
🌟 Selected for report: 3docSec
Also found by: 0xMosh, 0xadrii, 0xmystery, Bauchibred, Ch_301, Emmanuel, J4X, Madalad, Pechenite, Shaheen, Topmark, TresDelinquentes, Walter, ZanyBonzy, adeolu, adriro, chainsnake, joaovwfreire, lsaudit, osmanozdemir1, sin1st3r__
15.7808 USDC - $15.78
minTotalContribution
and minContribution
InteractionThe issue impacts the functionality and finalization of the ETHCrowdfundBase
contract.
Specifically, it arises from an incorrect interaction between the minTotalContribution
, maxTotalContribution
, and minContribution
parameters. When the difference between minTotalContribution
and maxTotalContribution
is smaller than minContribution
, the ETHCrowdfundBase
contract will end up in situation where the crowdfund cannot be finalized, even if there are enough contributors willing to participate.
In the ETHCrowdfundBase
contract, two critical parameters govern the crowdfund's behavior:
minTotalContribution
: This parameter specifies the minimum total contribution required for the crowdfund to be considered successful.
minContribution
: This parameter sets the minimum contribution amount required from individual contributors to participate in the crowdfund.
The issue arises when the difference between minTotalContribution
and maxTotalContribution
(the maximum allowed total contribution) is less than minContribution
. This scenario creates a problematic situation for the crowdfund:
The crowdfund has a goal defined by minTotalContribution
that must be reached for it to be considered successful (to can be finalized).
However, due to the narrow gap between minTotalContribution
and maxTotalContribution
, it becomes challenging for contributors to reach the goal.
If contributors collectively contribute enough to meet or exceed the minTotalContribution
, the condition for finalizing the crowdfund is met.
Nevertheless, because the difference between minTotalContribution
and maxTotalContribution
is smaller than minContribution
, the _processContribution()
function may prevent contributors from participating, causing the crowdfund to remain in an incomplete state.
This situation can result in a scenario where contributors are willing to contribute enough to meet the crowdfund's minimum goal (minTotalContribution
), but they are unable to do so due to the restrictive minContribution
check. As a result, the crowdfund may never be successfully finalized.
function _processContribution( address payable contributor, address delegate, uint96 amount ) internal returns (uint96 votingPower) { /// ...code... // Check that the contribution amount is at or below the maximum. uint96 maxContribution_ = maxContribution; if (amount > maxContribution_) { revert AboveMaximumContributionsError(amount, maxContribution_); } uint96 newTotalContributions = totalContributions + amount; uint96 maxTotalContributions_ = maxTotalContributions; if (newTotalContributions >= maxTotalContributions_) { totalContributions = maxTotalContributions_; // Finalize the crowdfund. // This occurs before refunding excess contribution to act as a // reentrancy guard. _finalize(maxTotalContributions_); // Refund excess contribution. uint96 refundAmount = newTotalContributions - maxTotalContributions; if (refundAmount > 0) { amount -= refundAmount; payable(msg.sender).transferEth(refundAmount); } } else { totalContributions = newTotalContributions; } // Check that the contribution amount is at or above the minimum. This // is done after `amount` is potentially reduced if refunding excess // contribution. There is a case where this prevents a crowdfunds from // reaching `maxTotalContributions` if the `minContribution` is greater // than the difference between `maxTotalContributions` and the current // `totalContributions`. In this scenario users will have to wait until // the crowdfund expires or a host finalizes after // `minTotalContribution` has been reached by calling `finalize()`. uint96 minContribution_ = minContribution; if (amount < minContribution_) { revert BelowMinimumContributionsError(amount, minContribution_); } /// ...code... }
Implement check in Crowdfund Initialization that ensure the difference between minTotalContribution
and maxTotalContribution
is greater than minContribution
.
function _initialize(ETHCrowdfundOptions memory opts) internal { // Set the minimum and maximum contribution amounts. if (opts.minContribution > opts.maxContribution) { revert MinGreaterThanMaxError( opts.minContribution, opts.maxContribution ); } minContribution = opts.minContribution; maxContribution = opts.maxContribution; // Set the min total contributions. if (opts.minTotalContributions > opts.maxTotalContributions) { revert MinGreaterThanMaxError( opts.minTotalContributions, opts.maxTotalContributions ); } minTotalContributions = opts.minTotalContributions; // Set the max total contributions. if (opts.maxTotalContributions == 0) { // Prevent this because when `maxTotalContributions` is 0 the // crowdfund is invalid in `getCrowdfundLifecycle()` meaning it has // never been initialized. revert MaxTotalContributionsCannotBeZeroError( opts.maxTotalContributions ); } maxTotalContributions = opts.maxTotalContributions; + if (maxTotalContributions - minTotalContributions < minContribution) { + revert(); + } // Set the party crowdfund is for. party = opts.party; // Set the crowdfund start and end timestamps. expiry = uint40(block.timestamp + opts.duration); // Set the exchange rate. if (opts.exchangeRateBps == 0) revert InvalidExchangeRateError(opts.exchangeRateBps); exchangeRateBps = opts.exchangeRateBps; // Set the funding split and its recipient. fundingSplitBps = opts.fundingSplitBps; fundingSplitRecipient = opts.fundingSplitRecipient; // Set whether to disable contributing for existing card. disableContributingForExistingCard = opts .disableContributingForExistingCard; }
delegationsByVoter[voter]
is not deleted after the party member tokens are burnedfunction testsForReports() public { // 1. Create Party ( Party party, IERC721[] memory preciousTokens, uint256[] memory preciousTokenIds ) = partyAdmin.createParty( partyImpl, PartyAdmin.PartyCreationMinimalOptions({ host1: address(danny), host2: address(this), passThresholdBps: 5000, totalVotingPower: 60, preciousTokenAddress: address(toadz), preciousTokenId: 1, rageQuitTimestamp: 0, feeBps: 0, feeRecipient: payable(0) }) ); DummySimpleProposalEngineImpl engInstance = DummySimpleProposalEngineImpl( address(party) ); // 2. Mint Governance NFTs for `john` and `danny` partyAdmin.mintGovNft(party, address(john), 20, address(john)); assertEq(party.votingPowerByTokenId(1), 20); assertEq(party.ownerOf(1), address(john)); partyAdmin.mintGovNft(party, address(danny), 20, address(danny)); assertEq(party.votingPowerByTokenId(2), 20); //@note the governance token id of `danny` is `2` assertEq(party.ownerOf(2), address(danny)); // 3. Danny delegate his voting power to `steve` vm.prank(address(danny)); party.delegateVotingPower(address(steve)); // 4. `john` Generate Proposal PartyGovernance.Proposal memory p1 = PartyGovernance.Proposal({ maxExecutableTime: 9999999999, proposalData: abi.encodePacked([0]), cancelDelay: uint40(1 days) }); john.makeProposal(party, p1, 2); // 5. Party Admin burn NFTs of `danny` vm.prank(address(partyAdmin)); party.burn(2); assertEq(party.delegationsByVoter(address(danny)), address(steve)); // steve is still in `delegationsByVoter` mapping }
PartyGovernanceNFT.sol#increaseVotingPower()
and PartyGovernanceNFT.sol#decreaseVotingPower()
add check if the tokenId is actually minted to the specified user token. this can lead to more serious bugs.emergencyExecuteDisabled
cannot be set to false
after disableEmergencyExecute()
. Add functionality that allow emergencyExecuteDisabled
to be set to false
, no only to true
PartyGovernanceNFT.sol#burn()
In the contract NatSpec: /// @notice Burn governance NFTs and remove their voting power. Can only /// be called by an authority before the party has started. /// @param tokenIds The IDs of the governance NFTs to burn.
In the contest page Update PartyGovernanceNFT.burn() to allow authorities to burn any Party card even after governance has started
rageQuitTimestamp
in PartyGovernanceNFT._initialize()
The functionality provided by the rageQuit()
feature is of great importance to Party participants as it serves as a safeguard allowing them to make an emergency withdrawal of their assets. In light of this, it would be a prudent and sensible default strategy to set the initial value of rageQuitTimestamp to a future date. This would provide Party members with the assurance that they can utilize the rageQuit function during the early phases of the Party if necessary.
https://github.com/code-423n4/2023-10-party/blob/main/contracts/party/PartyGovernance.sol#L855-L858
The emergencyExecute
feature serves as a safeguard, enabling the PartyDAO to execute arbitrary functions on behalf of the Party contract and manage assets when the Party contract encounters issues. This functionality proves invaluable when assets are stuck, offering Party members an avenue to recover their locked assets. In light of this, permitting any Host to deactivate the emergencyExecute()
capability appears to bestow an excessive degree of authority upon the role and necessitates a high level of trust in the Host role. Additionally, it's important to note that deactivating emergencyExecute
is an irreversible and permanent action. It may be worthwhile to contemplate granting activeMembers the ability to both enable and disable emergencyExecute
, thereby promoting a more balanced control mechanism.
ecrecover
not checked (Missing Validation)address signer = ecrecover(hash, v, r, s);
https://scsfg.io/hackers/signature-attacks/#missing-validation
propose()
veto()
opts.duration
is zero, and if it is then revert in ETHCrowdfundBase.sol#_initialize()
PartyGovernance.sol#supportsInterface()
interfaceId == 0x49064906
-> interfaceId == bytes4(0x49064906)
PartyGovernanceNFT.sol#function burn(uint256 tokenId)
is useless. Can be removed. Additionally, this function is never used.PartyGovernanceNFT.sol#addAuthority()
- add check if the authority
is EOA, and if it is then revert#0 - c4-pre-sort
2023-11-13T04:16:04Z
ydspa marked the issue as sufficient quality report
#1 - c4-judge
2023-11-19T18:11:55Z
gzeon-c4 marked the issue as grade-b
#2 - radeveth
2023-11-22T08:38:23Z
Hi, @gzeon-c4. Thank you for the quick judging!
I strongly believe that Low-01
is dup of #453.
#3 - radeveth
2023-11-23T14:36:34Z
Hey, @gzeon-c4.
Please review my Low-01
. It describes the same problem as #127 and #453.
So, I believe that Low-01
is dup of #127.
#4 - gzeon-c4
2023-11-23T15:01:42Z
[Low-01] is a dupe of #505 which is out-of-scope
#5 - radeveth
2023-11-23T15:12:21Z
[Low-01] is a dupe of #505 which is out-of-scope
Hey, @gzeon-c4!
This is not the case.
In the issue #127 writes:
An issue occurs when minContribution > maxTotalContribution - minTotalContribution.
In my Low-01
writes:
The issue arises when the difference between
minTotalContribution
andmaxTotalContribution
(the maximum allowed total contribution) is less thanminContribution
. This scenario creates a problematic situation for the crowdfund https://github.com/code-423n4/2023-10-party-findings/blob/main/data/Pechenite-Q.md#:~:text=The%20issue%20arises%20when%20the%20difference%20between%20minTotalContribution%20and%20maxTotalContribution%20(the%20maximum%20allowed%20total%20contribution)%20is%20less%20than%20minContribution.%20This%20scenario%20creates%20a%20problematic%20situation%20for%20the%20crowdfund%3A
#6 - gzeon-c4
2023-11-23T15:52:08Z
Low-01 failed to describe the entire issue
You also write
If contributors collectively contribute enough to meet or exceed the minTotalContribution
#127 relies on specifically pushing the contribution BELOW minTotalContribution so that the party will not be able to finalize despite host action. The issue Low-01 describe is same as https://github.com/code-423n4/2023-10-party/blob/b23c65d62a20921c709582b0b76b387f2bb9ebb5/contracts/crowdfund/ETHCrowdfundBase.sol#L247-L253 which render it out-of-scope
#7 - radeveth
2023-11-24T05:54:01Z
Hey, @gzeon-c4!
My [Low-01] also relies how the Crowdfund cannot be finalize despite host actions.
As in my report write:
This situation can result in a scenario where contributors are willing to contribute enough to meet the crowdfund's minimum goal (minTotalContribution), but they are unable to do so due to the restrictive minContribution check. As a result, the crowdfund may never be successfully finalized.
The issue arises when the difference between minTotalContribution and maxTotalContribution (the maximum allowed total contribution) is less than minContribution.
—-
This part is just an explanation for the crowdfund behaviour:
If contributors collectively contribute enough to meet or exceed the minTotalContribution, the condition for finalizing the crowdfund is met.
I really don't see how my Low-01 issue isn't a duplicate of #127. 🙂
Have a good one.