diff --git a/contracts/crowdfund/BuyCrowdfund.sol b/contracts/crowdfund/BuyCrowdfund.sol index 86ff8902..c7607adf 100644 --- a/contracts/crowdfund/BuyCrowdfund.sol +++ b/contracts/crowdfund/BuyCrowdfund.sol @@ -139,18 +139,30 @@ contract BuyCrowdfund is BuyCrowdfundBase { } } + // Check that the call value is under the maximum price. + { + uint96 maximumPrice_ = maximumPrice; + if (callValue > maximumPrice_) { + revert MaximumPriceError(callValue, maximumPrice_); + } + } + // Temporarily set to non-zero as a reentrancy guard. settledPrice = type(uint96).max; // Buy the NFT and check NFT is owned by the crowdfund. (bool success, bytes memory revertData) = _buy( nftContract, - nftTokenId, callTarget, callValue, callData ); + // Check that the NFT was bought. + if (nftContract.safeOwnerOf(nftTokenId) != address(this)) { + success = false; + } + if (!success) { if (revertData.length > 0) { revertData.rawRevert(); diff --git a/contracts/crowdfund/BuyCrowdfundBase.sol b/contracts/crowdfund/BuyCrowdfundBase.sol index de5efbd1..6dfba9e3 100644 --- a/contracts/crowdfund/BuyCrowdfundBase.sol +++ b/contracts/crowdfund/BuyCrowdfundBase.sol @@ -15,6 +15,7 @@ import "./Crowdfund.sol"; abstract contract BuyCrowdfundBase is Crowdfund { using LibSafeERC721 for IERC721; using LibSafeCast for uint256; + using LibRawResult for bytes; struct BuyCrowdfundBaseOptions { // The name of the crowdfund. @@ -97,7 +98,6 @@ abstract contract BuyCrowdfundBase is Crowdfund { // if it successfully buys the NFT. function _buy( IERC721 token, - uint256 tokenId, address payable callTarget, uint96 callValue, bytes memory callData @@ -106,20 +106,9 @@ abstract contract BuyCrowdfundBase is Crowdfund { if (!_isCallAllowed(callTarget, callData, token)) { revert CallProhibitedError(callTarget, callData); } - // Check that the call value is under the maximum price. - { - uint96 maximumPrice_ = maximumPrice; - if (callValue > maximumPrice_) { - revert MaximumPriceError(callValue, maximumPrice_); - } - } + // Execute the call to buy the NFT. - (bool s, bytes memory r) = callTarget.call{ value: callValue }(callData); - if (!s) { - return (false, r); - } - // Return whether the NFT was successfully bought. - return (token.safeOwnerOf(tokenId) == address(this), ""); + (success, revertData) = callTarget.call{ value: callValue }(callData); } function _finalize( diff --git a/contracts/crowdfund/CollectionBatchBuyCrowdfund.sol b/contracts/crowdfund/CollectionBatchBuyCrowdfund.sol index da2dad92..2720a278 100644 --- a/contracts/crowdfund/CollectionBatchBuyCrowdfund.sol +++ b/contracts/crowdfund/CollectionBatchBuyCrowdfund.sol @@ -60,25 +60,54 @@ contract CollectionBatchBuyCrowdfund is BuyCrowdfundBase { FixedGovernanceOpts governanceOpts; } + struct TokenToBuy { + // The token ID of the NFT to buy. + uint256 tokenId; + // The price of the token. This cannot be greater than `maximumPrice`. + uint96 price; + // The proof needed to verify that the token ID is included in the + // `nftTokenIdsMerkleRoot` (if it is not null). + bytes32[] proof; + } + + struct BuyCall { + // The contract to call to buy the NFTs in `tokensToBuy`. + address payable target; + // The calldata to call `target` with to buy the NFTs in `tokensToBuy`. + bytes data; + // The tokens to try buying with this call. + TokenToBuy[] tokensToBuy; + } + struct BatchBuyArgs { - uint256[] tokenIds; - address payable[] callTargets; - uint96[] callValues; - bytes[] callDatas; - bytes32[][] proofs; + // The calls made to buy the NFTs. Each call has a target, data, and + // the tokens to buy in that call. + BuyCall[] calls; + // The total number of tokens that can be bought in this batch buy. This + // should be equal to the sum of the each `tokensToBuy` in `calls`. + uint256 numOfTokens; + // Minimum number of tokens that must be purchased. If this limit is + // not reached, the batch buy will fail. uint256 minTokensBought; + // Minimum amount of ETH that must be used to buy the tokens. If this + // amount is not reached, the batch buy will fail. uint256 minTotalEthUsed; + // These are the governance options that will be used to create the + // governance `Party` if the crowdfund is successful. Additionally, they + // are used to verify that the caller is a host. FixedGovernanceOpts governanceOpts; + // The index of the host in `governanceOpts.hosts` that is making this + // batch buy. This is used to verify that the caller is a host. uint256 hostIndex; } error NothingBoughtError(); error InvalidMinTokensBoughtError(uint256 minTokensBought); error InvalidTokenIdError(); - error ContributionsSpentForFailedBuyError(); + error EthUsedForFailedBuyError(uint256 expectedEthUsed, uint256 actualEthUsed); error NotEnoughTokensBoughtError(uint256 tokensBought, uint256 minTokensBought); error NotEnoughEthUsedError(uint256 ethUsed, uint256 minTotalEthUsed); - error MismatchedCallArgLengthsError(); + error NumOfTokensCannotBeLessThanMin(uint256 numOfTokens, uint256 min); /// @notice The contract of NFTs to buy. IERC721 public nftContract; @@ -142,65 +171,79 @@ contract CollectionBatchBuyCrowdfund is BuyCrowdfundBase { revert InvalidMinTokensBoughtError(0); } - // Check length of all arg arrays. - if ( - args.tokenIds.length != args.callTargets.length || - args.tokenIds.length != args.callValues.length || - args.tokenIds.length != args.callDatas.length || - args.tokenIds.length != args.proofs.length - ) { - revert MismatchedCallArgLengthsError(); + if (args.numOfTokens < args.minTokensBought) { + // The number of tokens to buy must be greater than or equal to the + // minimum number of tokens to buy. + revert NumOfTokensCannotBeLessThanMin(args.numOfTokens, args.minTokensBought); } // Temporarily set to non-zero as a reentrancy guard. settledPrice = type(uint96).max; - uint96 totalEthUsed; - uint256 tokensBought; - IERC721[] memory tokens = new IERC721[](args.tokenIds.length); + // Lengths of arrays are updated at the end. + IERC721[] memory tokens = new IERC721[](args.numOfTokens); + uint256[] memory tokenIds = new uint256[](args.numOfTokens); + IERC721 token = nftContract; bytes32 root = nftTokenIdsMerkleRoot; - for (uint256 i; i < args.tokenIds.length; ++i) { - if (root != bytes32(0)) { - // Verify the token ID is in the merkle tree. - _verifyTokenId(args.tokenIds[i], root, args.proofs[i]); + uint96 maxPrice = maximumPrice; + uint96 totalEthUsed; + uint256 tokensBought; + for (uint256 i; i < args.calls.length; ++i) { + BuyCall memory call = args.calls[i]; + + uint96 callValue; + for (uint256 j; j < call.tokensToBuy.length; ++j) { + TokenToBuy memory tokenToBuy = call.tokensToBuy[j]; + + if (root != bytes32(0)) { + // Verify the token ID is in the merkle tree. + _verifyTokenId(tokenToBuy.tokenId, root, tokenToBuy.proof); + } + + // Check that the call value is under the maximum price. + uint96 price = tokenToBuy.price; + if (price > maxPrice) { + revert MaximumPriceError(price, maxPrice); + } + + // Add the price to the total value used for the call. + callValue += price; } - // Used to ensure no ETH is spent if the call fails. uint256 balanceBefore = address(this).balance; + { + // Execute the call to buy the NFTs. + (bool success, ) = _buy(token, call.target, callValue, call.data); - // Execute the call to buy the NFT. - (bool success, bytes memory revertData) = _buy( - token, - args.tokenIds[i], - args.callTargets[i], - args.callValues[i], - args.callDatas[i] - ); + if (!success) continue; + } - if (!success) { - if (args.minTokensBought >= args.tokenIds.length) { - // If the call failed with revert data, revert with that data. - if (revertData.length > 0) { - revertData.rawRevert(); - } else { - revert FailedToBuyNFTError(token, args.tokenIds[i]); - } - } else { - // If the call failed, ensure no ETH was spent and skip this NFT. - if (address(this).balance != balanceBefore) { - revert ContributionsSpentForFailedBuyError(); - } + { + uint96 ethUsed; + for (uint256 j; j < call.tokensToBuy.length; ++j) { + uint256 tokenId = call.tokensToBuy[j].tokenId; + uint96 price = call.tokensToBuy[j].price; + + // Check whether the NFT was successfully bought. + if (token.safeOwnerOf(tokenId) == address(this)) { + ethUsed += price; + ++tokensBought; - continue; + // Add the token to the list of tokens to finalize. + tokens[tokensBought - 1] = token; + tokenIds[tokensBought - 1] = tokenId; + } } - } - totalEthUsed += args.callValues[i]; + // Check ETH spent for call is what was expected. + uint256 actualEthUsed = balanceBefore - address(this).balance; + if (ethUsed != actualEthUsed) { + revert EthUsedForFailedBuyError(ethUsed, actualEthUsed); + } - ++tokensBought; - tokens[tokensBought - 1] = token; - args.tokenIds[tokensBought - 1] = args.tokenIds[i]; + totalEthUsed += ethUsed; + } } // This is to prevent this crowdfund from finalizing a loss if nothing @@ -222,13 +265,13 @@ contract CollectionBatchBuyCrowdfund is BuyCrowdfundBase { // Update length of `tokens` mstore(tokens, tokensBought) // Update length of `tokenIds` - mstore(0x1A0, tokensBought) + mstore(tokenIds, tokensBought) } return _finalize( tokens, - args.tokenIds, + tokenIds, totalEthUsed, args.governanceOpts, // If `_assertIsHost()` succeeded, the governance opts were validated. diff --git a/contracts/crowdfund/CollectionBuyCrowdfund.sol b/contracts/crowdfund/CollectionBuyCrowdfund.sol index 0aff7c8b..9daa8fc2 100644 --- a/contracts/crowdfund/CollectionBuyCrowdfund.sol +++ b/contracts/crowdfund/CollectionBuyCrowdfund.sol @@ -124,18 +124,30 @@ contract CollectionBuyCrowdfund is BuyCrowdfundBase { } } + // Check that the call value is under the maximum price. + { + uint96 maximumPrice_ = maximumPrice; + if (callValue > maximumPrice_) { + revert MaximumPriceError(callValue, maximumPrice_); + } + } + // Temporarily set to non-zero as a reentrancy guard. settledPrice = type(uint96).max; // Buy the NFT and check NFT is owned by the crowdfund. (bool success, bytes memory revertData) = _buy( nftContract, - tokenId, callTarget, callValue, callData ); + // Check that the NFT was bought. + if (nftContract.safeOwnerOf(tokenId) != address(this)) { + success = false; + } + if (!success) { if (revertData.length > 0) { revertData.rawRevert(); diff --git a/sol-tests/crowdfund/CollectionBatchBuyCrowdfund.t.sol b/sol-tests/crowdfund/CollectionBatchBuyCrowdfund.t.sol index ff32780d..10200cf6 100644 --- a/sol-tests/crowdfund/CollectionBatchBuyCrowdfund.t.sol +++ b/sol-tests/crowdfund/CollectionBatchBuyCrowdfund.t.sol @@ -26,6 +26,7 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { Globals globals; DummyERC721 nftContract; + DummyBatchMinter batchMinter; MockParty party; uint96 maximumPrice = 100e18; @@ -38,6 +39,7 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { party = partyFactory.mockParty(); nftContract = new DummyERC721(); + batchMinter = new DummyBatchMinter(); govOpts.hosts.push(address(this)); } @@ -101,16 +103,22 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { // Setup parameters to batch buy. IERC721[] memory tokens = new IERC721[](3); uint256[] memory tokenIds = new uint256[](3); - address payable[] memory callTargets = new address payable[](3); - uint96[] memory callValues = new uint96[](3); - bytes[] memory callDatas = new bytes[](3); - bytes32[][] memory proofs = new bytes32[][](3); - for (uint256 i; i < tokenIds.length; i++) { + CollectionBatchBuyCrowdfund.BuyCall[] + memory calls = new CollectionBatchBuyCrowdfund.BuyCall[](3); + for (uint256 i; i < calls.length; ++i) { tokens[i] = nftContract; tokenIds[i] = i + 1; - callTargets[i] = payable(address(nftContract)); - callValues[i] = 1; - callDatas[i] = abi.encodeCall(nftContract.mint, (address(cf))); + + CollectionBatchBuyCrowdfund.TokenToBuy[] + memory tokensToBuy = new CollectionBatchBuyCrowdfund.TokenToBuy[](1); + tokensToBuy[0].tokenId = i + 1; + tokensToBuy[0].price = 1; + + calls[i] = CollectionBatchBuyCrowdfund.BuyCall({ + target: payable(address(nftContract)), + data: abi.encodeCall(nftContract.mint, (address(cf))), + tokensToBuy: tokensToBuy + }); } // Buy the tokens. vm.expectEmit(false, false, false, true); @@ -136,11 +144,8 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { ); Party party_ = cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, + calls: calls, + numOfTokens: tokenIds.length, minTokensBought: tokenIds.length, minTotalEthUsed: 0, governanceOpts: govOpts, @@ -161,34 +166,38 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { cf.contribute{ value: contributor.balance }(delegate, ""); // Setup parameters to batch buy. IERC721[] memory tokens = new IERC721[](3); - uint256[] memory tokenIds = new uint256[](3); - address payable[] memory callTargets = new address payable[](3); - uint96[] memory callValues = new uint96[](3); - bytes[] memory callDatas = new bytes[](3); - bytes32[][] memory proofs = new bytes32[][](3); - for (uint256 i; i < tokenIds.length; i++) { + CollectionBatchBuyCrowdfund.BuyCall[] + memory calls = new CollectionBatchBuyCrowdfund.BuyCall[](3); + for (uint256 i; i < calls.length; ++i) { tokens[i] = nftContract; - tokenIds[i] = i + 1; - callTargets[i] = payable(address(nftContract)); - callValues[i] = 1; - callDatas[i] = abi.encodeCall(nftContract.mint, (address(cf))); + + CollectionBatchBuyCrowdfund.TokenToBuy[] + memory tokensToBuy = new CollectionBatchBuyCrowdfund.TokenToBuy[](1); + tokensToBuy[0].tokenId = i + 1; + tokensToBuy[0].price = 1; + + // Ensure the last call will fail to buy a token. + if (i != calls.length - 1) { + calls[i] = CollectionBatchBuyCrowdfund.BuyCall({ + target: payable(address(nftContract)), + data: abi.encodeCall(nftContract.mint, (address(cf))), + tokensToBuy: tokensToBuy + }); + } } vm.expectRevert( abi.encodeWithSelector( CollectionBatchBuyCrowdfund.NotEnoughTokensBoughtError.selector, - 3, - 4 + 2, + 3 ) ); // Buy the tokens. cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, - minTokensBought: tokenIds.length + 1, + calls: calls, + numOfTokens: tokens.length, + minTokensBought: tokens.length, minTotalEthUsed: 0, governanceOpts: govOpts, hostIndex: 0 @@ -207,30 +216,31 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { cf.contribute{ value: contributor.balance }(delegate, ""); // Setup parameters to batch buy. IERC721[] memory tokens = new IERC721[](3); - uint256[] memory tokenIds = new uint256[](3); - address payable[] memory callTargets = new address payable[](3); - uint96[] memory callValues = new uint96[](3); - bytes[] memory callDatas = new bytes[](3); - bytes32[][] memory proofs = new bytes32[][](3); - for (uint256 i; i < tokenIds.length; i++) { + CollectionBatchBuyCrowdfund.BuyCall[] + memory calls = new CollectionBatchBuyCrowdfund.BuyCall[](3); + for (uint256 i; i < calls.length; ++i) { tokens[i] = nftContract; - tokenIds[i] = i + 1; - callTargets[i] = payable(address(nftContract)); - callValues[i] = 1; - callDatas[i] = abi.encodeCall(nftContract.mint, (address(cf))); + + CollectionBatchBuyCrowdfund.TokenToBuy[] + memory tokensToBuy = new CollectionBatchBuyCrowdfund.TokenToBuy[](1); + tokensToBuy[0].tokenId = i + 1; + tokensToBuy[0].price = 1; + + calls[i] = CollectionBatchBuyCrowdfund.BuyCall({ + target: payable(address(nftContract)), + data: abi.encodeCall(nftContract.mint, (address(cf))), + tokensToBuy: tokensToBuy + }); } + // Buy the tokens. vm.expectRevert( abi.encodeWithSelector(CollectionBatchBuyCrowdfund.NotEnoughEthUsedError.selector, 3, 4) ); - // Buy the tokens. cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, - minTokensBought: tokenIds.length, + calls: calls, + numOfTokens: tokens.length, + minTokensBought: tokens.length, minTotalEthUsed: 4, governanceOpts: govOpts, hostIndex: 0 @@ -248,29 +258,29 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { vm.prank(contributor); cf.contribute{ value: contributor.balance }(delegate, ""); // Setup parameters to batch buy. - IERC721[] memory tokens = new IERC721[](3); - uint256[] memory tokenIds = new uint256[](3); - tokenIds[0] = 1; - tokenIds[2] = 2; - address payable[] memory callTargets = new address payable[](3); - uint96[] memory callValues = new uint96[](3); - bytes[] memory callDatas = new bytes[](3); - bytes32[][] memory proofs = new bytes32[][](3); - for (uint256 i; i < tokenIds.length; i++) { + CollectionBatchBuyCrowdfund.BuyCall[] + memory calls = new CollectionBatchBuyCrowdfund.BuyCall[](3); + for (uint256 i; i < calls.length; ++i) { // Ensure one token will fail to be bought if (i == 1) continue; - tokens[i] = nftContract; - callTargets[i] = payable(address(nftContract)); - callValues[i] = 1; - callDatas[i] = abi.encodeCall(nftContract.mint, (address(cf))); + CollectionBatchBuyCrowdfund.TokenToBuy[] + memory tokensToBuy = new CollectionBatchBuyCrowdfund.TokenToBuy[](1); + tokensToBuy[0].tokenId = i == 0 ? 1 : 2; + tokensToBuy[0].price = 1; + + calls[i] = CollectionBatchBuyCrowdfund.BuyCall({ + target: payable(address(nftContract)), + data: abi.encodeCall(nftContract.mint, (address(cf))), + tokensToBuy: tokensToBuy + }); } // Check that token length is updated from 3 to 2 when creating the party - IERC721[] memory expectedTokens = new IERC721[](2); - uint256[] memory expectedTokenIds = new uint256[](2); - for (uint256 i; i < expectedTokenIds.length; i++) { - expectedTokens[i] = nftContract; - expectedTokenIds[i] = i + 1; + IERC721[] memory tokens = new IERC721[](2); + uint256[] memory tokenIds = new uint256[](2); + for (uint256 i; i < tokenIds.length; i++) { + tokens[i] = nftContract; + tokenIds[i] = i + 1; } vm.expectEmit(false, false, false, true); emit MockPartyFactoryCreateParty( @@ -290,17 +300,14 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { feeRecipient: govOpts.feeRecipient }) }), - expectedTokens, - expectedTokenIds + tokens, + tokenIds ); // Buy the tokens. cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, + calls: calls, + numOfTokens: 2, minTokensBought: tokenIds.length - 1, minTotalEthUsed: 0, governanceOpts: govOpts, @@ -312,12 +319,6 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { function test_batchBuy_cannotMinTokensBoughtZero() public { // Create the crowdfund. CollectionBatchBuyCrowdfund cf = _createCrowdfund(); - // Setup parameters to batch buy. - uint256[] memory tokenIds = new uint256[](0); - address payable[] memory callTargets = new address payable[](0); - uint96[] memory callValues = new uint96[](0); - bytes[] memory callDatas = new bytes[](0); - bytes32[][] memory proofs = new bytes32[][](0); // Buy the tokens. vm.expectRevert( abi.encodeWithSelector( @@ -327,11 +328,8 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { ); cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, + calls: new CollectionBatchBuyCrowdfund.BuyCall[](0), + numOfTokens: 0, minTokensBought: 0, minTotalEthUsed: 0, governanceOpts: govOpts, @@ -343,21 +341,12 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { function test_batchBuy_cannotTriggerLostByNotBuyingAnything() public { // Create the crowdfund. CollectionBatchBuyCrowdfund cf = _createCrowdfund(); - // Setup parameters to batch buy. - uint256[] memory tokenIds = new uint256[](0); - address payable[] memory callTargets = new address payable[](0); - uint96[] memory callValues = new uint96[](0); - bytes[] memory callDatas = new bytes[](0); - bytes32[][] memory proofs = new bytes32[][](0); // Buy the tokens. vm.expectRevert(CollectionBatchBuyCrowdfund.NothingBoughtError.selector); cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, + calls: new CollectionBatchBuyCrowdfund.BuyCall[](0), + numOfTokens: 1, minTokensBought: 1, minTotalEthUsed: 0, governanceOpts: govOpts, @@ -370,15 +359,15 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { // Create the crowdfund. CollectionBatchBuyCrowdfund cf = _createCrowdfund(); // Setup parameters to batch buy. - IERC721[] memory tokens = new IERC721[](3); - uint256[] memory tokenIds = new uint256[](3); - address payable[] memory callTargets = new address payable[](3); - uint96[] memory callValues = new uint96[](3); - bytes[] memory callDatas = new bytes[](3); - bytes32[][] memory proofs = new bytes32[][](3); - for (uint256 i; i < tokenIds.length; i++) { - tokens[i] = nftContract; - tokenIds[i] = i + 1; + CollectionBatchBuyCrowdfund.BuyCall[] + memory calls = new CollectionBatchBuyCrowdfund.BuyCall[](3); + for (uint256 i; i < calls.length; ++i) { + CollectionBatchBuyCrowdfund.TokenToBuy[] + memory tokensToBuy = new CollectionBatchBuyCrowdfund.TokenToBuy[](1); + tokensToBuy[0].tokenId = i + 1; + + calls[i].tokensToBuy = tokensToBuy; + // Mint tokens to crowdfund for free. nftContract.mint(address(cf)); } @@ -386,44 +375,9 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { vm.expectRevert(CollectionBatchBuyCrowdfund.NothingBoughtError.selector); cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, - minTokensBought: tokenIds.length, - minTotalEthUsed: 0, - governanceOpts: govOpts, - hostIndex: 0 - }) - ); - } - - function test_batchBuy_failedToBuy() public { - // Create the crowdfund. - CollectionBatchBuyCrowdfund cf = _createCrowdfund(); - // Setup parameters to batch buy. - uint256[] memory tokenIds = new uint256[](3); - address payable[] memory callTargets = new address payable[](3); - uint96[] memory callValues = new uint96[](3); - bytes[] memory callDatas = new bytes[](3); - bytes32[][] memory proofs = new bytes32[][](3); - // Buy the tokens. - vm.expectRevert( - abi.encodeWithSelector( - BuyCrowdfundBase.FailedToBuyNFTError.selector, - address(nftContract), - 0 - ) - ); - cf.batchBuy( - CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, - minTokensBought: tokenIds.length, + calls: calls, + numOfTokens: 3, + minTokensBought: 3, minTotalEthUsed: 0, governanceOpts: govOpts, hostIndex: 0 @@ -441,21 +395,27 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { vm.prank(contributor); cf.contribute{ value: contributor.balance }(delegate, ""); // Setup parameters to batch buy. - uint256[] memory tokenIds = new uint256[](2); - address payable[] memory callTargets = new address payable[](2); - uint96[] memory callValues = new uint96[](2); - callValues[0] = 1e18; - bytes[] memory callDatas = new bytes[](2); - bytes32[][] memory proofs = new bytes32[][](2); + CollectionBatchBuyCrowdfund.BuyCall[] + memory calls = new CollectionBatchBuyCrowdfund.BuyCall[](2); + for (uint256 i; i < calls.length; ++i) { + CollectionBatchBuyCrowdfund.TokenToBuy[] + memory tokensToBuy = new CollectionBatchBuyCrowdfund.TokenToBuy[](1); + // Spend ETH on this failed buy. + tokensToBuy[0].price = 1e18; + calls[i].tokensToBuy = tokensToBuy; + } // Buy the tokens. - vm.expectRevert(CollectionBatchBuyCrowdfund.ContributionsSpentForFailedBuyError.selector); + vm.expectRevert( + abi.encodeWithSelector( + CollectionBatchBuyCrowdfund.EthUsedForFailedBuyError.selector, + 0, + 1e18 + ) + ); cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, + calls: calls, + numOfTokens: 2, minTokensBought: 1, minTotalEthUsed: 0, governanceOpts: govOpts, @@ -468,12 +428,18 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { // Create the crowdfund. CollectionBatchBuyCrowdfund cf = _createCrowdfund(); // Setup parameters to batch buy. - uint256[] memory tokenIds = new uint256[](3); - address payable[] memory callTargets = new address payable[](3); - uint96[] memory callValues = new uint96[](3); - callValues[0] = maximumPrice + 1; - bytes[] memory callDatas = new bytes[](3); - bytes32[][] memory proofs = new bytes32[][](3); + CollectionBatchBuyCrowdfund.BuyCall[] + memory calls = new CollectionBatchBuyCrowdfund.BuyCall[](1); + CollectionBatchBuyCrowdfund.TokenToBuy[] + memory tokensToBuy = new CollectionBatchBuyCrowdfund.TokenToBuy[](1); + tokensToBuy[0].tokenId = 1; + // Set the price to be above the maximum price. + tokensToBuy[0].price = maximumPrice + 1; + calls[0] = CollectionBatchBuyCrowdfund.BuyCall({ + target: payable(address(nftContract)), + data: abi.encodeCall(nftContract.mint, (address(cf))), + tokensToBuy: tokensToBuy + }); // Buy the tokens. vm.expectRevert( abi.encodeWithSelector( @@ -484,12 +450,9 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { ); cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, - minTokensBought: tokenIds.length, + calls: calls, + numOfTokens: 1, + minTokensBought: 1, minTotalEthUsed: 0, governanceOpts: govOpts, hostIndex: 0 @@ -501,22 +464,26 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { // Create the crowdfund. CollectionBatchBuyCrowdfund cf = _createCrowdfund(); // Setup parameters to batch buy. - uint256[] memory tokenIds = new uint256[](3); - address payable[] memory callTargets = new address payable[](3); - uint96[] memory callValues = new uint96[](3); - bytes[] memory callDatas = new bytes[](3); - bytes32[][] memory proofs = new bytes32[][](3); + CollectionBatchBuyCrowdfund.BuyCall[] + memory calls = new CollectionBatchBuyCrowdfund.BuyCall[](3); + for (uint256 i; i < calls.length; ++i) { + CollectionBatchBuyCrowdfund.TokenToBuy[] + memory tokensToBuy = new CollectionBatchBuyCrowdfund.TokenToBuy[](1); + tokensToBuy[0].tokenId = i + 1; + calls[i] = CollectionBatchBuyCrowdfund.BuyCall({ + target: payable(address(nftContract)), + data: abi.encodeCall(nftContract.mint, (address(cf))), + tokensToBuy: tokensToBuy + }); + } // Buy the tokens. vm.prank(_randomAddress()); vm.expectRevert(Crowdfund.OnlyPartyHostError.selector); cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, - minTokensBought: tokenIds.length, + calls: calls, + numOfTokens: 3, + minTokensBought: 3, minTotalEthUsed: 0, governanceOpts: govOpts, hostIndex: 0 @@ -528,23 +495,28 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { // Create the crowdfund. CollectionBatchBuyCrowdfund cf = _createCrowdfund(); // Setup parameters to batch buy. - uint256[] memory tokenIds = new uint256[](3); - address payable[] memory callTargets = new address payable[](3); - uint96[] memory callValues = new uint96[](3); - bytes[] memory callDatas = new bytes[](3); - bytes32[][] memory proofs = new bytes32[][](3); + CollectionBatchBuyCrowdfund.BuyCall[] + memory calls = new CollectionBatchBuyCrowdfund.BuyCall[](3); + for (uint256 i; i < calls.length; ++i) { + CollectionBatchBuyCrowdfund.TokenToBuy[] + memory tokensToBuy = new CollectionBatchBuyCrowdfund.TokenToBuy[](1); + tokensToBuy[0].tokenId = i + 1; + tokensToBuy[0].price = 0; + calls[i] = CollectionBatchBuyCrowdfund.BuyCall({ + target: payable(address(nftContract)), + data: abi.encodeCall(nftContract.mint, (address(cf))), + tokensToBuy: tokensToBuy + }); + } // Mutate governance options govOpts.hosts.push(_randomAddress()); // Buy the tokens. vm.expectRevert(Crowdfund.InvalidGovernanceOptionsError.selector); cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, - minTokensBought: tokenIds.length, + calls: calls, + numOfTokens: 3, + minTokensBought: 3, minTotalEthUsed: 0, governanceOpts: govOpts, hostIndex: 0 @@ -562,26 +534,23 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { vm.prank(contributor); cf.contribute{ value: contributor.balance }(contributor, ""); // Setup parameters to batch buy. - IERC721[] memory tokens = new IERC721[](1); - uint256[] memory tokenIds = new uint256[](1); - address payable[] memory callTargets = new address payable[](1); - uint96[] memory callValues = new uint96[](1); - bytes[] memory callDatas = new bytes[](1); - bytes32[][] memory proofs = new bytes32[][](1); - tokens[0] = nftContract; - tokenIds[0] = tokenId; - callTargets[0] = payable(address(nftContract)); - callValues[0] = 1; - callDatas[0] = abi.encodeCall(nftContract.mint, (address(cf))); + CollectionBatchBuyCrowdfund.BuyCall[] + memory calls = new CollectionBatchBuyCrowdfund.BuyCall[](1); + CollectionBatchBuyCrowdfund.TokenToBuy[] + memory tokensToBuy = new CollectionBatchBuyCrowdfund.TokenToBuy[](1); + tokensToBuy[0].tokenId = tokenId; + tokensToBuy[0].price = 1; + calls[0] = CollectionBatchBuyCrowdfund.BuyCall({ + target: payable(address(nftContract)), + data: abi.encodeCall(nftContract.mint, (address(cf))), + tokensToBuy: tokensToBuy + }); // Buy the tokens. cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, - minTokensBought: tokenIds.length, + calls: calls, + numOfTokens: 1, + minTokensBought: 1, minTotalEthUsed: 0, governanceOpts: govOpts, hostIndex: 0 @@ -599,28 +568,88 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { vm.prank(contributor); cf.contribute{ value: contributor.balance }(contributor, ""); // Setup parameters to batch buy. - IERC721[] memory tokens = new IERC721[](1); - uint256[] memory tokenIds = new uint256[](1); - address payable[] memory callTargets = new address payable[](1); - uint96[] memory callValues = new uint96[](1); - bytes[] memory callDatas = new bytes[](1); - bytes32[][] memory proofs = new bytes32[][](1); - tokens[0] = nftContract; - tokenIds[0] = tokenId; - callTargets[0] = payable(address(nftContract)); - callValues[0] = 1; - callDatas[0] = abi.encodeCall(nftContract.mint, (address(cf))); - proofs[0] = new bytes32[](1); + CollectionBatchBuyCrowdfund.BuyCall[] + memory calls = new CollectionBatchBuyCrowdfund.BuyCall[](1); + CollectionBatchBuyCrowdfund.TokenToBuy[] + memory tokensToBuy = new CollectionBatchBuyCrowdfund.TokenToBuy[](1); + tokensToBuy[0].tokenId = tokenId; + tokensToBuy[0].price = 1; + tokensToBuy[0].proof = new bytes32[](1); + calls[0] = CollectionBatchBuyCrowdfund.BuyCall({ + target: payable(address(nftContract)), + data: abi.encodeCall(nftContract.mint, (address(cf))), + tokensToBuy: tokensToBuy + }); // Buy the tokens. vm.expectRevert(CollectionBatchBuyCrowdfund.InvalidTokenIdError.selector); cf.batchBuy( CollectionBatchBuyCrowdfund.BatchBuyArgs({ - tokenIds: tokenIds, - callTargets: callTargets, - callValues: callValues, - callDatas: callDatas, - proofs: proofs, - minTokensBought: tokenIds.length, + calls: calls, + numOfTokens: 1, + minTokensBought: 1, + minTotalEthUsed: 0, + governanceOpts: govOpts, + hostIndex: 0 + }) + ); + } + + function test_batchBuy_multipleTokensBoughtPerCall() public { + // Create the crowdfund. + CollectionBatchBuyCrowdfund cf = _createCrowdfund(); + // Contribute and delegate. + address payable contributor = _randomAddress(); + address delegate = _randomAddress(); + vm.deal(contributor, 1e18); + vm.prank(contributor); + cf.contribute{ value: contributor.balance }(delegate, ""); + // Setup parameters to batch buy. + IERC721[] memory tokens = new IERC721[](6); + uint256[] memory tokenIds = new uint256[](6); + CollectionBatchBuyCrowdfund.BuyCall[] + memory calls = new CollectionBatchBuyCrowdfund.BuyCall[](2); + for (uint256 i = 0; i < calls.length; ++i) { + CollectionBatchBuyCrowdfund.TokenToBuy[] + memory tokensToBuy = new CollectionBatchBuyCrowdfund.TokenToBuy[](3); + for (uint256 j = 0; j < tokensToBuy.length; ++j) { + uint256 tokenId = i * tokensToBuy.length + j + 1; + tokens[tokenId - 1] = nftContract; + tokensToBuy[j].tokenId = tokenIds[tokenId - 1] = tokenId; + tokensToBuy[j].price = 1; + } + calls[i] = CollectionBatchBuyCrowdfund.BuyCall({ + target: payable(address(batchMinter)), + data: abi.encodeCall(batchMinter.batchMint, (nftContract, 3)), + tokensToBuy: tokensToBuy + }); + } + vm.expectEmit(false, false, false, true); + emit MockPartyFactoryCreateParty( + address(cf), + address(cf), + Party.PartyOptions({ + name: "Crowdfund", + symbol: "CF", + customizationPresetId: 0, + governance: PartyGovernance.GovernanceOpts({ + hosts: govOpts.hosts, + voteDuration: govOpts.voteDuration, + executionDelay: govOpts.executionDelay, + passThresholdBps: govOpts.passThresholdBps, + totalVotingPower: 6, + feeBps: govOpts.feeBps, + feeRecipient: govOpts.feeRecipient + }) + }), + tokens, + tokenIds + ); + // Buy the tokens. + cf.batchBuy( + CollectionBatchBuyCrowdfund.BatchBuyArgs({ + calls: calls, + numOfTokens: 6, + minTokensBought: 6, minTotalEthUsed: 0, governanceOpts: govOpts, hostIndex: 0 @@ -628,3 +657,11 @@ contract CollectionBatchBuyCrowdfundTest is Test, TestUtils { ); } } + +contract DummyBatchMinter { + function batchMint(DummyERC721 nftContract, uint256 tokensToMint) public payable { + for (uint256 i; i < tokensToMint; ++i) { + nftContract.mint(msg.sender); + } + } +}