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: 65/65
Findings: 1
Award: $15.78
๐ Selected for report: 0
๐ Solo Findings: 0
๐ 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
[L-01] Wrong amounts may be emitted in the ETHCrowdfundBase::_processContribution()
[L-02] Pre-existing ETH may not be credited to the initialContributor
[NC-01] The protocol does not allow contributing in the last second
[NC-02] Developer comments about the BatchContributeArgs
struct in InitialETHCrowdfund.sol
file is not complete.
[NC-03] NatSpec explanation in the PartyGovernance.sol::distribute()
is misleading
[NC-04] Hosts can still veto after the veto period is passed.
[NC-05] Users are not allowed to vote at the last second
ETHCrowdfundBase::_processContribution()
https://github.com/code-423n4/2023-10-party/blob/b23c65d62a20921c709582b0b76b387f2bb9ebb5/contracts/crowdfund/ETHCrowdfundBase.sol#L210C51-L210C57
https://github.com/code-423n4/2023-10-party/blob/b23c65d62a20921c709582b0b76b387f2bb9ebb5/contracts/crowdfund/ETHCrowdfundBase.sol#L240
This protocol allows users to contribute to crowdfunding. If the user's contribution causes the total contribution amount to exceed the maximum total contribution amount, excess funds are refunded to the user.
However, the Contributed
event in this _processContribution()
function is emitted with the user's input amount, not the actual contributed amount.
function _processContribution( address payable contributor, address delegate, uint96 amount ) internal returns (uint96 votingPower) { // skipped for brevity 210.--> emit Contributed(msg.sender, contributor, amount, delegate); //@audit - This even is emitted with user input "amount" // skipped for brevity // Refund excess contribution. uint96 refundAmount = newTotalContributions - maxTotalContributions; if (refundAmount > 0) { 240.--> amount -= refundAmount; //@audit - if the max total is reached, amount is decreased. There will be a mismatch between the event if this happens. 241.--> payable(msg.sender).transferEth(refundAmount); } } else { totalContributions = newTotalContributions; }
If there is a refund due to maxTotalContributions
is reached, there will be a mismatch between the amount
in the emitted event and the actual contribution amount.
Recommendation: Emit the event after the contribution amount is certain.
initialContributor
// If the deployer passed in some ETH during deployment, credit them // for the initial contribution. uint96 initialContribution = msg.value.safeCastUint256ToUint96(); if (initialContribution > 0) { // If this contract has ETH, either passed in during deployment or // pre-existing, credit it to the `initialContributor`. //@audit QA - if this contract has pre-existing ETH but initialContribution (msg.value) is 0, these ETH will not be creditted at all because the function will not enter this if statement. _contribute( crowdfundOpts.initialContributor, crowdfundOpts.initialDelegate, initialContribution, 0, "" ); }
If the deployer passes some ETH during the deployment of the crowdfunding contracts, these ETH are contributed on behalf of initialContributor
.
Above, we can see the developer's comments: "If this contract has ETH, either passed in during deployment or pre-existing, credit it to the `initialContributor`"
Unfortunately, this may not be true in some cases.
If the contract has pre-existing ETH, but the msg.value
is 0 during the deployment, those pre-existing ETH will not be contributed on behalf of the initialContributor
because the function will not enter the "if statement" at all.
Recommendation: I would recommend checking the balance of the contract and transferring those pre-existing ETH to the initialContributor
even if the msg.value
is 0.
if (block.timestamp >= expiry_) { //@audit it doesn't allow contributing in the last second. if (totalContributions >= minTotalContributions) { return CrowdfundLifecycle.Won; } else { return CrowdfundLifecycle.Lost; }
As we can see above, crowdfunding status checks the expiry time to determine if the crowdfunding is lost or won. However, the check is wrong due to >=
operator.
It doesn't allow contributions in the last second but these contributions made in the last second could have changed the status from lost
to won
.
Recommendation: use block.timestamp > expiry_
instead of block.timestamp >= expiry_
BatchContributeArgs
struct in InitialETHCrowdfund.sol
file is not complete.struct BatchContributeArgs { // IDs of cards to credit the contributions to. When set to 0, it means //@audit QA - the explanation is not complete. uint256[] tokenIds;
Developer comments about the BatchContributeArgs
struct is not complete.
PartyGovernance.sol::distribute()
is misleadingPart of the NatSpec explanation above the PartyGovernance.sol::distribute()
function is: "Create a token distribution by moving the party's entire balance to the TokenDistributor
contract and immediately creating a distribution governed by this party."
This comment is misleading because the function is not transferring the party's entire balance. It only transfers the inputted amount
while creating the distribution.
/// @notice Create a token distribution by moving the party's entire balance /// to the `TokenDistributor` contract and immediately creating a /// distribution governed by this party. // skipped for brevity. function distribute( uint256 amount, ITokenDistributor.TokenType tokenType, address token, uint256 tokenId ) external returns (ITokenDistributor.DistributionInfo memory distInfo) { // skipped for brevity if (tokenType == ITokenDistributor.TokenType.Native) { return --> distributor.createNativeDistribution{ value: amount }( //@audit QA - amount is transferred not the entire balance Party(payable(address(this))), feeRecipient_, feeBps_ ); } // Otherwise must be an ERC20 token distribution. assert(tokenType == ITokenDistributor.TokenType.Erc20); --> IERC20(token).compatTransfer(address(distributor), amount); //@audit QA - amount is transferred not the entire balance return distributor.createErc20Distribution( IERC20(token), Party(payable(address(this))), feeRecipient_, feeBps_ ); }
Recommendation: Consider updating the comment above the function.
In the protocol's contest page, the execution delay for a proposal is used with the same meaning as the veto period. An example from the contest page: "Add functionality to skip proposalโs veto period if all hosts have accepted it"
In the PartyGovernance.sol
contract ProposalStatus
enum:
// States a proposal can be in. enum ProposalStatus { // skipped for brevity // The proposal reached at least `passThresholdBps` of votes but is still // waiting for `executionDelay` to pass before it can be executed. Members // can continue to vote on the proposal and party hosts can veto at this time. //@audit QA - can they veto only this time? Passed, // Same as `Passed` but now `executionDelay` has been satisfied. Any member // may execute the proposal via `execute()`, unless `maxExecutableTime` // has arrived. Ready, // skipped for brevity }
It gives the idea that the hosts are only allowed to veto proposals during the executionDelay
after the proposal is passed.
However, in the code itself, hosts can veto a proposal even if it is ready (which means the veto period (a.k.a execution delay) has already passed.)
file: PartyGovernance.sol //function veto() // Proposal must be in one of the following states. if ( status != ProposalStatus.Voting && status != ProposalStatus.Passed && --> status != ProposalStatus.Ready //@audit QA - Isn't the veto period is between passed and ready. A ready veto should not be vetoed. ) { revert BadProposalStatusError(status); }
Recommendation: It depends on the intention of the protocol. Either the hosts should not be allowed to veto after the proposal is ready, or the ability of the hosts should be explained in the docs/comments.
// Voting window expired. if (pv.proposedTime + gv.voteDuration <= t) { //@audit - it should be "<" instead of "<=" return ProposalStatus.Defeated; }
As we can see above, the voteDuration
of the proposal is added to proposedTime
, but it doesn't allow voting in the last second of the voting period.
Even though it might be an extreme case, a vote at the last second could change the whole voting status from Defeated
to Passed
.
Recommendation: Include the last second of the voting to the voting period and use <
instead of <=
.
#0 - c4-pre-sort
2023-11-13T04:17:11Z
ydspa marked the issue as insufficient quality report
#1 - c4-judge
2023-11-19T17:41:10Z
gzeon-c4 marked the issue as grade-b
#2 - osmanozdemir1
2023-11-23T17:30:39Z
Hi @gzeon-c4 Thanks for the judging.
I would like to point out [NC-04] in this QA report.
I submitted [NC-04] as QA due to hosts being considered as trusted in the protocol's contests page. However, in #233, a malicious host issue is considered as valid. Therefore I wanted to flag [NC-04] since there is a privilege escalation in that issue due to incorrect check in the code.
Here in the protocol's documents (under Glossary/Governance):
Veto Period - A fixed amount of time that each proposal must wait to be executed after passing. During this time, any Party Host can veto the proposal.
Veto period is clearly defined as fixed amount of time, which is between passed
and ready
. Hosts can unilaterally veto proposals. However, hosts must do that during the veto period
.
In the current implementation, hosts can veto proposals even after the veto period, which is privilege escalation. If #233 is validated, [NC-04] should be considered as medium.
Thanks again for all of your efforts while judging this contest. Kind regards, osmanozdemir1
#3 - gzeon-c4
2023-11-23T17:49:09Z
Thanks for the ping, I would still consider this as QA since this seems to be a minor function incorrect as to spec
instead of being exploitable. For instance, they can easily veto during the executionDelay.