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: 36/65
Findings: 2
Award: $168.15
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: TresDelinquentes
Also found by: 3docSec, Arz, Bauchibred, D1r3Wolf, J4X, Neon2835, Pechenite, adeolu, chainsnake
152.3655 USDC - $152.37
When a members contributes to a crowdfund via contribute(), the user donates a specified amount of eth to the crowdfund contract. The amount must be above the minContribution per user and lower than the maxContribution per user. If there are multiple members, the total amount of contributions must never exceed the maxTotalContributions
and a crowdfund is considered sucessfull when the total amount of contributions is more than the minTotalContributions value.
When contributing, if a user donates an eth amount which takes the totalContributions
above the maxTotalContributions
, the user is refunded the eth differece and the amount donated is adjusted to an amount that just takes the totalContributions
to be equal to the maxTotalContributions
. It is possible that during the refund process, the adjusted contribution amount for the user will be below the minContribution
per user. And this will cause a revert as seen here
if (amount < minContribution_) { revert BelowMinimumContributionsError(amount, minContribution_); }
Lets have a scenario where minTotalContributions is 4.5 eth, maxTotalContributions is 5 eth, minContribution per user is 2 eth. If two members donate 2 eth each, the totalContributions becomes 4 eth. 0.5 eth is left to make the contributions up to minTotalContributions so that the crowdfund can be considered won and finalized. A third user proceeds to donate 2 eth, which is the minContribution per user. The system takes the 2 eth and because 2 eth will take the totalContributions up to 6 eth, the user gets refunded 1 eth so that totalContributions is eq to the maxTotalContributions of 5 eth. The third user contribution amount becomes 1 eth, but since 1 eth is not accepted because it is below minContribution (2 eth) per user the function reverts. This leaves the party crowdfund in a precarious situation as :
minTotalContributions
will never be reached.Note: the comments here says that it recognizes a similar scenario where "crowdfunds may not reach maxTotalContributions
" and suggests that the members wait till the crowdfund is expired so that the host can finalize but what of the case where minTotalContributions
is also never reached like my scenario describes? This will mean that the finalization will not be possible by the host and the crowdfund will be lost unfairly.
I wrote a test POC to illustrate this bug. See it below:
NOTE: paste code below into new file in the test/crowdfund
folder and run with forge test --mt test_Case_02
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8; import "forge-std/Test.sol"; import {Clones} from "openzeppelin/contracts/proxy/Clones.sol"; import "../../contracts/crowdfund/InitialETHCrowdfund.sol"; import "../../contracts/globals/Globals.sol"; import "../../contracts/party/PartyFactory.sol"; import "../../contracts/tokens/ERC721Receiver.sol"; import "../../contracts/renderers/PartyNFTRenderer.sol"; import "../../contracts/renderers/MetadataRegistry.sol"; import "../../contracts/renderers/RendererStorage.sol"; import "../../contracts/renderers/fonts/PixeldroidConsoleFont.sol"; import "../../contracts/distribution/TokenDistributor.sol"; import "../../contracts/gatekeepers/AllowListGateKeeper.sol"; import "../TestUtils.sol"; import {LintJSON} from "../utils/LintJSON.sol"; contract InitialETHCrowdfundTestBase is LintJSON, TestUtils, ERC721Receiver { using Clones for address; event Contributed( address indexed sender, address indexed contributor, uint256 amount, address delegate ); event Refunded( address indexed contributor, uint256 indexed tokenId, uint256 amount ); event PartyDelegateUpdated(address indexed owner, address indexed delegate); InitialETHCrowdfund initialETHCrowdfundImpl; Globals globals; Party partyImpl; PartyFactory partyFactory; PartyNFTRenderer nftRenderer; RendererStorage nftRendererStorage; TokenDistributor tokenDistributor; InitialETHCrowdfund.ETHPartyOptions partyOpts; InitialETHCrowdfund.InitialETHCrowdfundOptions crowdfundOpts; constructor() { globals = new Globals(address(this)); partyImpl = new Party(globals); partyFactory = new PartyFactory(globals); initialETHCrowdfundImpl = new InitialETHCrowdfund(globals); MetadataRegistry metadataRegistry = new MetadataRegistry( globals, new address[](0) ); // Upload font on-chain PixeldroidConsoleFont font = new PixeldroidConsoleFont(); nftRendererStorage = new RendererStorage(address(this)); nftRenderer = new PartyNFTRenderer( globals, nftRendererStorage, font, address(0), "https://party.app/party/" ); tokenDistributor = new TokenDistributor(globals, 0); globals.setAddress( LibGlobals.GLOBAL_GOVERNANCE_NFT_RENDER_IMPL, address(nftRenderer) ); globals.setAddress( LibGlobals.GLOBAL_RENDERER_STORAGE, address(nftRendererStorage) ); globals.setAddress( LibGlobals.GLOBAL_TOKEN_DISTRIBUTOR, address(tokenDistributor) ); globals.setAddress( LibGlobals.GLOBAL_METADATA_REGISTRY, address(metadataRegistry) ); // Generate customization options. uint256 versionId = 1; uint256 numOfColors = uint8(type(Color).max) + 1; for (uint256 i; i < numOfColors; ++i) { // Generate customization options for all colors w/ each mode (light and dark). nftRendererStorage.createCustomizationPreset( // Preset ID 0 is reserved. It is used to indicates to party instances // to use the same customization preset as the crowdfund. i + 1, abi.encode(versionId, false, Color(i)) ); nftRendererStorage.createCustomizationPreset( i + 1 + numOfColors, abi.encode(versionId, true, Color(i)) ); } } struct CreateCrowdfundArgs { uint96 initialContribution; address payable initialContributor; address initialDelegate; uint96 minContributions; uint96 maxContributions; bool disableContributingForExistingCard; uint96 minTotalContributions; uint96 maxTotalContributions; uint40 duration; uint16 exchangeRateBps; uint16 fundingSplitBps; address payable fundingSplitRecipient; IGateKeeper gateKeeper; bytes12 gateKeeperId; } function _createCrowdfund( CreateCrowdfundArgs memory args, bool initialize ) internal returns (InitialETHCrowdfund crowdfund) { crowdfundOpts.initialContributor = args.initialContributor; crowdfundOpts.initialDelegate = args.initialDelegate; crowdfundOpts.minContribution = args.minContributions; crowdfundOpts.maxContribution = args.maxContributions; crowdfundOpts.disableContributingForExistingCard = args .disableContributingForExistingCard; crowdfundOpts.minTotalContributions = args.minTotalContributions; crowdfundOpts.maxTotalContributions = args.maxTotalContributions; crowdfundOpts.duration = args.duration; crowdfundOpts.exchangeRateBps = args.exchangeRateBps; crowdfundOpts.fundingSplitBps = args.fundingSplitBps; crowdfundOpts.fundingSplitRecipient = args.fundingSplitRecipient; crowdfundOpts.gateKeeper = args.gateKeeper; crowdfundOpts.gateKeeperId = args.gateKeeperId; partyOpts.name = "Test Party"; partyOpts.symbol = "TEST"; partyOpts.governanceOpts.partyImpl = partyImpl; partyOpts.governanceOpts.partyFactory = partyFactory; partyOpts.governanceOpts.voteDuration = 7 days; partyOpts.governanceOpts.executionDelay = 1 days; partyOpts.governanceOpts.passThresholdBps = 0.5e4; partyOpts.governanceOpts.hosts = new address[](1); partyOpts.governanceOpts.hosts[0] = address(this); crowdfund = InitialETHCrowdfund( payable(address(initialETHCrowdfundImpl).clone()) ); if (initialize) { crowdfund.initialize{value: args.initialContribution}( crowdfundOpts, partyOpts, MetadataProvider(address(0)), "" ); } } function _createCrowdfund( CreateCrowdfundArgs memory args ) internal returns (InitialETHCrowdfund) { return _createCrowdfund(args, true); } } contract InitialETHCrowdfundTest is InitialETHCrowdfundTestBase { using Clones for address; function test_Case_02() public { /** create new crowdfund with minContributions = 2 ether, minTotalContributions = 4.5 ether, maxTotalContributions = 5 ether **/ uint96 minContributuonPerUser = 2 ether; uint96 max_total_contributions = 5 ether; uint96 min_total_contributions = 4.5 ether; InitialETHCrowdfund crowdfund = _createCrowdfund( CreateCrowdfundArgs({ initialContribution: 0, initialContributor: payable(address(0)), initialDelegate: address(0), minContributions: minContributuonPerUser, maxContributions: type(uint96).max, disableContributingForExistingCard: false, minTotalContributions: min_total_contributions, maxTotalContributions: max_total_contributions, duration: 7 days, exchangeRateBps: 1e4, fundingSplitBps: 100, fundingSplitRecipient: payable(_randomAddress()), gateKeeper: IGateKeeper(address(0)), gateKeeperId: bytes12(0) }) ); //user 1 donates 2 eth address user_one = _randomAddress(); uint96 user_one_contribution = 2 ether; vm.deal(user_one, user_one_contribution); vm.prank(user_one); crowdfund.contribute{value: user_one_contribution}(user_one, ""); //user 2 donates 2 eth address user_two = _randomAddress(); uint96 user_two_contribution = 2 ether; vm.deal(user_two, user_two_contribution); vm.prank(user_two); crowdfund.contribute{value: user_two_contribution}(user_two, ""); //user 3 tries to donate 2 eth address user_three = _randomAddress(); uint96 user_three_contribution = 2 ether; vm.deal(user_three, user_three_contribution); // since user 3 donated 2 eth, and previous users 1 and 2 donated 2 eth each, totalContributions is 4 eth, maxTotalContribution is 5. // user3 donation takes totalContributions to 6 but this is rejected by the system and the contribution of the user 3 is set to 1 eth // so that totalContributions will be eq to 5, the maxTotalContribution. // At this poinit, user 3 contribution amount is adjusted to 1 eth, and 1 eth is lower than the minimumContributionPerUser // so we expect a revert of the 1 ether contribution amount for user 3 vm.expectRevert( abi.encodeWithSelector( ETHCrowdfundBase.BelowMinimumContributionsError.selector, 1 ether, minContributuonPerUser ) ); vm.prank(user_three); crowdfund.contribute{value: user_three_contribution}(user_three, ""); //now user 3 cannot contribute because 1 ether is needed to make it up to maxTotalContribution and 1 ether amount is below minContributionPer user so it always reverts. //totalContributions is 4 ether and not up to minTotalContribution(4.5 ether) so this crowdfund can never be finalized. //It will be lost even though there are members willing to donate up to the minTotalContribution. } }
manual review, foundry
check that amount > minContribution
before the refund. This way, the modified amount after refund is done will allow for maxTotalContributions === totalContributions
.
DoS
#0 - c4-pre-sort
2023-11-12T07:43:40Z
ydspa marked the issue as duplicate of #552
#1 - c4-pre-sort
2023-11-12T07:43:44Z
ydspa marked the issue as insufficient quality report
#2 - c4-judge
2023-11-19T14:33:05Z
gzeon-c4 marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2023-11-19T14:40:14Z
gzeon-c4 marked the issue as unsatisfactory: Out of scope
#4 - c4-judge
2023-11-23T14:16:12Z
gzeon-c4 marked the issue as unsatisfactory: Out of scope
#5 - c4-judge
2023-11-23T14:21:40Z
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
in the ETHCrowdfundBase
contract, fundingSplitBps
is a variable who's value denotes the the portion of contributions to send to the funding recipient. In practice it is set as basis points that represent percentages where 100 = 1% and 1e4/10_000 = 100%. See here
The function convertVotingPowerToContribution()
calculate the contribution amount from the given voting power. If the fundingSplitBps is set to 1e4 (at deploy by party hosts) this means that 100% of the contributions should be sent to the funding receipient. convertVotingPowerToContribution()
function will always revert because of its faulty math logic computation as seen below.
if (fundingSplitRecipient_ != address(0) && fundingSplitBps_ > 0) { amount = (amount * 1e4) / (1e4 - fundingSplitBps_); }
if fundingSplitBps_
is 100% or 1e4/10_000, the denominatior in the math computation above will be 0. Since divisions by zero is unallowed in solidity, the convertVotingPowerToContribution() will revert()/throw panic error.
if fundingSplitBps
is set to be 100% (1e4) during initialization via _initialize(). In the convertVotingPowerToContribution()
code logic below we can see that in the calculation of amount
where (1e4 - fundingSplitBps_)
this will be eq to 0.
function convertVotingPowerToContribution( uint96 votingPower ) public view returns (uint96 amount) { amount = (votingPower * 1e4) / exchangeRateBps; // Add back funding split to contribution amount if applicable. address payable fundingSplitRecipient_ = fundingSplitRecipient; uint16 fundingSplitBps_ = fundingSplitBps; if (fundingSplitRecipient_ != address(0) && fundingSplitBps_ > 0) { amount = (amount * 1e4) / (1e4 - fundingSplitBps_); } }
In the amount calculation, this will mean that a 0 value will be the denominator i.e amount = (amount * 1e4) / (0)
. This division operation will be reverted because solidity doesnt allow divisions by 0. Thus convertVotingPowerToContribution() will be impacted.
copy and paste into new file in the test/crowdfund
folder. Run with forge test --mt test_Case_01
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8; import "forge-std/Test.sol"; import { Clones } from "openzeppelin/contracts/proxy/Clones.sol"; import "../../contracts/crowdfund/InitialETHCrowdfund.sol"; import "../../contracts/globals/Globals.sol"; import "../../contracts/party/PartyFactory.sol"; import "../../contracts/tokens/ERC721Receiver.sol"; import "../../contracts/renderers/PartyNFTRenderer.sol"; import "../../contracts/renderers/MetadataRegistry.sol"; import "../../contracts/renderers/RendererStorage.sol"; import "../../contracts/renderers/fonts/PixeldroidConsoleFont.sol"; import "../../contracts/distribution/TokenDistributor.sol"; import "../../contracts/gatekeepers/AllowListGateKeeper.sol"; import "../TestUtils.sol"; import { LintJSON } from "../utils/LintJSON.sol"; contract InitialETHCrowdfundTestBase is LintJSON, TestUtils, ERC721Receiver { using Clones for address; event Contributed( address indexed sender, address indexed contributor, uint256 amount, address delegate ); event Refunded(address indexed contributor, uint256 indexed tokenId, uint256 amount); event PartyDelegateUpdated(address indexed owner, address indexed delegate); InitialETHCrowdfund initialETHCrowdfundImpl; Globals globals; Party partyImpl; PartyFactory partyFactory; PartyNFTRenderer nftRenderer; RendererStorage nftRendererStorage; TokenDistributor tokenDistributor; InitialETHCrowdfund.ETHPartyOptions partyOpts; InitialETHCrowdfund.InitialETHCrowdfundOptions crowdfundOpts; constructor() { globals = new Globals(address(this)); partyImpl = new Party(globals); partyFactory = new PartyFactory(globals); initialETHCrowdfundImpl = new InitialETHCrowdfund(globals); MetadataRegistry metadataRegistry = new MetadataRegistry(globals, new address[](0)); // Upload font on-chain PixeldroidConsoleFont font = new PixeldroidConsoleFont(); nftRendererStorage = new RendererStorage(address(this)); nftRenderer = new PartyNFTRenderer( globals, nftRendererStorage, font, address(0), "https://party.app/party/" ); tokenDistributor = new TokenDistributor(globals, 0); globals.setAddress(LibGlobals.GLOBAL_GOVERNANCE_NFT_RENDER_IMPL, address(nftRenderer)); globals.setAddress(LibGlobals.GLOBAL_RENDERER_STORAGE, address(nftRendererStorage)); globals.setAddress(LibGlobals.GLOBAL_TOKEN_DISTRIBUTOR, address(tokenDistributor)); globals.setAddress(LibGlobals.GLOBAL_METADATA_REGISTRY, address(metadataRegistry)); // Generate customization options. uint256 versionId = 1; uint256 numOfColors = uint8(type(Color).max) + 1; for (uint256 i; i < numOfColors; ++i) { // Generate customization options for all colors w/ each mode (light and dark). nftRendererStorage.createCustomizationPreset( // Preset ID 0 is reserved. It is used to indicates to party instances // to use the same customization preset as the crowdfund. i + 1, abi.encode(versionId, false, Color(i)) ); nftRendererStorage.createCustomizationPreset( i + 1 + numOfColors, abi.encode(versionId, true, Color(i)) ); } } struct CreateCrowdfundArgs { uint96 initialContribution; address payable initialContributor; address initialDelegate; uint96 minContributions; uint96 maxContributions; bool disableContributingForExistingCard; uint96 minTotalContributions; uint96 maxTotalContributions; uint40 duration; uint16 exchangeRateBps; uint16 fundingSplitBps; address payable fundingSplitRecipient; IGateKeeper gateKeeper; bytes12 gateKeeperId; } function _createCrowdfund( CreateCrowdfundArgs memory args, bool initialize ) internal returns (InitialETHCrowdfund crowdfund) { crowdfundOpts.initialContributor = args.initialContributor; crowdfundOpts.initialDelegate = args.initialDelegate; crowdfundOpts.minContribution = args.minContributions; crowdfundOpts.maxContribution = args.maxContributions; crowdfundOpts.disableContributingForExistingCard = args.disableContributingForExistingCard; crowdfundOpts.minTotalContributions = args.minTotalContributions; crowdfundOpts.maxTotalContributions = args.maxTotalContributions; crowdfundOpts.duration = args.duration; crowdfundOpts.exchangeRateBps = args.exchangeRateBps; crowdfundOpts.fundingSplitBps = args.fundingSplitBps; crowdfundOpts.fundingSplitRecipient = args.fundingSplitRecipient; crowdfundOpts.gateKeeper = args.gateKeeper; crowdfundOpts.gateKeeperId = args.gateKeeperId; partyOpts.name = "Test Party"; partyOpts.symbol = "TEST"; partyOpts.governanceOpts.partyImpl = partyImpl; partyOpts.governanceOpts.partyFactory = partyFactory; partyOpts.governanceOpts.voteDuration = 7 days; partyOpts.governanceOpts.executionDelay = 1 days; partyOpts.governanceOpts.passThresholdBps = 0.5e4; partyOpts.governanceOpts.hosts = new address[](1); partyOpts.governanceOpts.hosts[0] = address(this); crowdfund = InitialETHCrowdfund(payable(address(initialETHCrowdfundImpl).clone())); if (initialize) { crowdfund.initialize{ value: args.initialContribution }( crowdfundOpts, partyOpts, MetadataProvider(address(0)), "" ); } } function _createCrowdfund( CreateCrowdfundArgs memory args ) internal returns (InitialETHCrowdfund) { return _createCrowdfund(args, true); } } contract InitialETHCrowdfundTest is InitialETHCrowdfundTestBase { using Clones for address; function test_Case_01() public { //set fundingSplitBps to max 100% and deploy crowdfund uint16 maxfundingSplitBps = 1e4; InitialETHCrowdfund crowdfund = _createCrowdfund( CreateCrowdfundArgs({ initialContribution: 0, initialContributor: payable(address(0)), initialDelegate: address(0), minContributions: 0, maxContributions: type(uint96).max, disableContributingForExistingCard: false, minTotalContributions: 3 ether, maxTotalContributions: 5 ether, duration: 7 days, exchangeRateBps: 1e4, fundingSplitBps: maxfundingSplitBps, fundingSplitRecipient: payable(_randomAddress()), gateKeeper: IGateKeeper(address(0)), gateKeeperId: bytes12(0) }) ); // call convertVotingPowerToContribution() and expect it to revert because of improper division by 0 vm.expectRevert(); crowdfund.convertVotingPowerToContribution(100); } }
manual review
check that 1e4 - fundingSplitBps_
is not zero before the division and if zero, set amount
to be zero.
Math
#0 - ydspa
2023-11-11T10:56:04Z
QA: NC
#1 - c4-pre-sort
2023-11-11T10:56:13Z
ydspa marked the issue as insufficient quality report
#2 - c4-pre-sort
2023-11-11T15:48:15Z
ydspa marked the issue as primary issue
#3 - c4-judge
2023-11-19T16:05:57Z
gzeon-c4 changed the severity to QA (Quality Assurance)
#4 - c4-judge
2023-11-20T18:30:54Z
gzeon-c4 marked the issue as grade-b