diff --git a/.gitmodules b/.gitmodules index dc951e5b..2130c990 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,12 @@ [submodule "lib/party-addresses"] path = lib/party-addresses url = https://github.com/PartyDAO/party-addresses +[submodule "lib/v2-periphery"] + path = lib/v2-periphery + url = https://github.com/Uniswap/v2-periphery +[submodule "lib/v2-core"] + path = lib/v2-core + url = https://github.com/Uniswap/v2-core +[submodule "lib/erc20-creator"] + path = lib/erc20-creator + url = https://github.com/PartyDAO/erc20-creator diff --git a/contracts/crowdfund/CrowdfundFactory.sol b/contracts/crowdfund/CrowdfundFactory.sol index 3166f3cd..df5f589d 100644 --- a/contracts/crowdfund/CrowdfundFactory.sol +++ b/contracts/crowdfund/CrowdfundFactory.sol @@ -12,6 +12,7 @@ import { CollectionBuyCrowdfund } from "./CollectionBuyCrowdfund.sol"; import { RollingAuctionCrowdfund } from "./RollingAuctionCrowdfund.sol"; import { CollectionBatchBuyCrowdfund } from "./CollectionBatchBuyCrowdfund.sol"; import { InitialETHCrowdfund, ETHCrowdfundBase } from "./InitialETHCrowdfund.sol"; +import { ERC20LaunchCrowdfund } from "./ERC20LaunchCrowdfund.sol"; import { MetadataProvider } from "../renderers/MetadataProvider.sol"; import { Party } from "../party/Party.sol"; @@ -53,6 +54,14 @@ contract CrowdfundFactory { InitialETHCrowdfund.InitialETHCrowdfundOptions crowdfundOpts, InitialETHCrowdfund.ETHPartyOptions partyOpts ); + event ERC20LaunchCrowdfundCreated( + address indexed creator, + ERC20LaunchCrowdfund indexed crowdfund, + Party indexed party, + ERC20LaunchCrowdfund.InitialETHCrowdfundOptions crowdfundOpts, + ERC20LaunchCrowdfund.ETHPartyOptions partyOpts, + ERC20LaunchCrowdfund.ERC20LaunchOptions tokenOpts + ); /// @notice Create a new crowdfund to purchase a specific NFT (i.e., with a /// known token ID) listing for a known price. @@ -199,6 +208,57 @@ contract CrowdfundFactory { emit InitialETHCrowdfundCreated(msg.sender, inst, inst.party(), crowdfundOpts, partyOpts); } + function createERC20LaunchCrowdfund( + ERC20LaunchCrowdfund crowdfundImpl, + ERC20LaunchCrowdfund.InitialETHCrowdfundOptions memory crowdfundOpts, + ERC20LaunchCrowdfund.ETHPartyOptions memory partyOpts, + ERC20LaunchCrowdfund.ERC20LaunchOptions memory tokenOpts, + bytes memory createGateCallData + ) external payable returns (ERC20LaunchCrowdfund inst) { + return + createERC20LaunchCrowdfundWithMetadata( + crowdfundImpl, + crowdfundOpts, + partyOpts, + tokenOpts, + MetadataProvider(address(0)), + "", + createGateCallData + ); + } + + function createERC20LaunchCrowdfundWithMetadata( + ERC20LaunchCrowdfund crowdfundImpl, + ERC20LaunchCrowdfund.InitialETHCrowdfundOptions memory crowdfundOpts, + ERC20LaunchCrowdfund.ETHPartyOptions memory partyOpts, + ERC20LaunchCrowdfund.ERC20LaunchOptions memory tokenOpts, + MetadataProvider customMetadataProvider, + bytes memory customMetadata, + bytes memory createGateCallData + ) public payable returns (ERC20LaunchCrowdfund inst) { + crowdfundOpts.gateKeeperId = _prepareGate( + crowdfundOpts.gateKeeper, + crowdfundOpts.gateKeeperId, + createGateCallData + ); + inst = ERC20LaunchCrowdfund(address(crowdfundImpl).clone()); + inst.initialize{ value: msg.value }( + crowdfundOpts, + partyOpts, + tokenOpts, + customMetadataProvider, + customMetadata + ); + emit ERC20LaunchCrowdfundCreated( + msg.sender, + inst, + inst.party(), + crowdfundOpts, + partyOpts, + tokenOpts + ); + } + function _prepareGate( IGateKeeper gateKeeper, bytes12 gateKeeperId, diff --git a/contracts/crowdfund/ERC20LaunchCrowdfund.sol b/contracts/crowdfund/ERC20LaunchCrowdfund.sol new file mode 100644 index 00000000..acdb2b81 --- /dev/null +++ b/contracts/crowdfund/ERC20LaunchCrowdfund.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.20; + +import { InitialETHCrowdfund } from "./InitialETHCrowdfund.sol"; +import { Party } from "../party/Party.sol"; +import { MetadataProvider } from "../renderers/MetadataProvider.sol"; +import { IGlobals } from "../globals/IGlobals.sol"; +import { IERC20Creator, TokenConfiguration, ERC20 } from "../utils/IERC20Creator.sol"; + +/// @notice A crowdfund for launching ERC20 tokens. +/// Unlike other crowdfunds that are started for the purpose of +/// acquiring NFT(s), this crowdfund bootstraps an ERC20 token +/// and sends a share of the total supply to the new party. +contract ERC20LaunchCrowdfund is InitialETHCrowdfund { + struct ERC20LaunchOptions { + // The name of the ERC20 token launched. + string name; + // The symbol of the ERC20 token launched. + string symbol; + // An arbitrary address to receive ERC20 tokens. + address recipient; + // The total supply to mint for the ERC20 token. + uint256 totalSupply; + // The number of tokens to distribute to the party. + uint256 numTokensForDistribution; + // The number of tokens to send to an arbitrary recipient. + uint256 numTokensForRecipient; + // The number of tokens to use for the Uniswap LP pair. + uint256 numTokensForLP; + } + + error InvalidTokenDistribution(); + error TokenAlreadyLaunched(); + + IERC20Creator public immutable ERC20_CREATOR; + + ERC20LaunchOptions public tokenOpts; + + bool public isTokenLaunched; + + constructor(IGlobals globals, IERC20Creator erc20Creator) InitialETHCrowdfund(globals) { + ERC20_CREATOR = erc20Creator; + } + + /// @notice Initializer to be called prior to using the contract. + /// @param crowdfundOpts Options to initialize the crowdfund with. + /// @param partyOpts Options to initialize the party with. + /// @param customMetadataProvider Optional provider to use for the party for + /// rendering custom metadata. + /// @param customMetadata Optional custom metadata to use for the party. + function initialize( + InitialETHCrowdfundOptions memory crowdfundOpts, + ETHPartyOptions memory partyOpts, + ERC20LaunchOptions memory _tokenOpts, + MetadataProvider customMetadataProvider, + bytes memory customMetadata + ) external payable { + if ( + _tokenOpts.numTokensForDistribution + + _tokenOpts.numTokensForRecipient + + _tokenOpts.numTokensForLP != + _tokenOpts.totalSupply || + _tokenOpts.totalSupply > type(uint112).max || + _tokenOpts.numTokensForLP < 1e4 || + crowdfundOpts.fundingSplitBps > 5e3 || + crowdfundOpts.minTotalContributions < 1e4 + ) { + revert InvalidTokenDistribution(); + } + + tokenOpts = _tokenOpts; + + InitialETHCrowdfund.initialize( + crowdfundOpts, + partyOpts, + customMetadataProvider, + customMetadata + ); + } + + /// @notice Launch the ERC20 token for the Party. + function launchToken() public returns (ERC20 token) { + if (isTokenLaunched) revert TokenAlreadyLaunched(); + + CrowdfundLifecycle lc = getCrowdfundLifecycle(); + if (lc != CrowdfundLifecycle.Finalized) revert WrongLifecycleError(lc); + + isTokenLaunched = true; + + // Update the party's total voting power + uint96 totalContributions_ = totalContributions; + + uint16 fundingSplitBps_ = fundingSplitBps; + if (fundingSplitBps_ > 0) { + // Assuming fundingSplitBps_ <= 1e4, this cannot overflow uint96 + totalContributions_ -= uint96((uint256(totalContributions_) * fundingSplitBps_) / 1e4); + } + + // Create the ERC20 token. + ERC20LaunchOptions memory _tokenOpts = tokenOpts; + token = ERC20_CREATOR.createToken{ value: totalContributions_ }( + address(party), + _tokenOpts.name, + _tokenOpts.symbol, + TokenConfiguration({ + totalSupply: _tokenOpts.totalSupply, + numTokensForDistribution: _tokenOpts.numTokensForDistribution, + numTokensForRecipient: _tokenOpts.numTokensForRecipient, + numTokensForLP: _tokenOpts.numTokensForLP + }), + _tokenOpts.recipient + ); + } + + /// @notice Finalize the crowdfund and launch the ERC20 token. + function finalize() public override { + super.finalize(); + launchToken(); + } + + function _finalize(uint96 totalContributions_) internal override { + // Finalize the crowdfund. + delete expiry; + + // Transfer funding split to recipient if applicable. + uint16 fundingSplitBps_ = fundingSplitBps; + if (fundingSplitBps_ > 0) { + // Assuming fundingSplitBps_ <= 1e4, this cannot overflow uint96 + totalContributions_ -= uint96((uint256(totalContributions_) * fundingSplitBps_) / 1e4); + } + + // Update the party's total voting power. + uint96 newVotingPower = _calculateContributionToVotingPower(totalContributions_); + party.increaseTotalVotingPower(newVotingPower); + + emit Finalized(); + } +} diff --git a/contracts/crowdfund/ETHCrowdfundBase.sol b/contracts/crowdfund/ETHCrowdfundBase.sol index a63c7495..69a3184c 100644 --- a/contracts/crowdfund/ETHCrowdfundBase.sol +++ b/contracts/crowdfund/ETHCrowdfundBase.sol @@ -293,7 +293,7 @@ abstract contract ETHCrowdfundBase is Implementation { function _calculateContributionToVotingPower( uint96 contribution - ) private view returns (uint96) { + ) internal view returns (uint96) { return contribution.mulDivDown(exchangeRate, 1e18).safeCastUint256ToUint96(); } @@ -325,7 +325,8 @@ abstract contract ETHCrowdfundBase is Implementation { return contribution; } - function finalize() external { + /// @notice Finalize the crowdfund and transfer the funds to the Party. + function finalize() public virtual { uint96 totalContributions_ = totalContributions; // Check that the crowdfund is not already finalized. @@ -351,7 +352,7 @@ abstract contract ETHCrowdfundBase is Implementation { _finalize(totalContributions_); } - function _finalize(uint96 totalContributions_) internal { + function _finalize(uint96 totalContributions_) internal virtual { // Finalize the crowdfund. delete expiry; diff --git a/contracts/crowdfund/InitialETHCrowdfund.sol b/contracts/crowdfund/InitialETHCrowdfund.sol index 3e5eca13..72545e2d 100644 --- a/contracts/crowdfund/InitialETHCrowdfund.sol +++ b/contracts/crowdfund/InitialETHCrowdfund.sol @@ -112,7 +112,7 @@ contract InitialETHCrowdfund is ETHCrowdfundBase { ETHPartyOptions memory partyOpts, MetadataProvider customMetadataProvider, bytes memory customMetadata - ) external payable onlyInitialize { + ) public payable onlyInitialize { // Create party the initial crowdfund will be for. Party party_ = _createParty(partyOpts, customMetadataProvider, customMetadata); diff --git a/contracts/utils/IERC20Creator.sol b/contracts/utils/IERC20Creator.sol new file mode 100644 index 00000000..cb807f48 --- /dev/null +++ b/contracts/utils/IERC20Creator.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.20; + +import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; + +struct TokenConfiguration { + uint256 totalSupply; + uint256 numTokensForDistribution; + uint256 numTokensForRecipient; + uint256 numTokensForLP; +} + +interface IERC20Creator { + function createToken( + address partyAddress, + string calldata name, + string calldata symbol, + TokenConfiguration calldata config, + address recipientAddress + ) external payable returns (ERC20 token); +} diff --git a/lib/erc20-creator b/lib/erc20-creator new file mode 160000 index 00000000..9370b7a1 --- /dev/null +++ b/lib/erc20-creator @@ -0,0 +1 @@ +Subproject commit 9370b7a149d53a30ee23424d98c5f08e346bed61 diff --git a/lib/forge-std b/lib/forge-std index 2f43c7e6..2f6762e4 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 2f43c7e69b820910e9d4f3b8cc8d3b4e6382786e +Subproject commit 2f6762e4f73f3d835457c220b5f62dfeeb6f6341 diff --git a/lib/party-addresses b/lib/party-addresses index 9e3ee3c4..eeab8a7c 160000 --- a/lib/party-addresses +++ b/lib/party-addresses @@ -1 +1 @@ -Subproject commit 9e3ee3c4fef00420388e37bbb98167cc6d4fd497 +Subproject commit eeab8a7c92e24e9d985a835d2b24f599d0465968 diff --git a/lib/v2-core b/lib/v2-core new file mode 160000 index 00000000..4dd59067 --- /dev/null +++ b/lib/v2-core @@ -0,0 +1 @@ +Subproject commit 4dd59067c76dea4a0e8e4bfdda41877a6b16dedc diff --git a/lib/v2-periphery b/lib/v2-periphery new file mode 160000 index 00000000..0335e8f7 --- /dev/null +++ b/lib/v2-periphery @@ -0,0 +1 @@ +Subproject commit 0335e8f7e1bd1e8d8329fd300aea2ef2f36dd19f diff --git a/remappings.txt b/remappings.txt index 8df44c43..f11a9d76 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,3 +1,8 @@ forge-std/=lib/forge-std/src/ openzeppelin/=lib/openzeppelin-contracts/ -solmate/=lib/solmate/src/ \ No newline at end of file +solmate/=lib/solmate/src/ +ds-test/=lib/forge-std/lib/ds-test/src/ +party-addresses/=lib/party-addresses/ +uniswap-v2-core/=lib/v2-core/contracts/ +uniswap-v2-periphery/=lib/v2-periphery/contracts/ +erc20-creator/=lib/erc20-creator/src \ No newline at end of file diff --git a/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol b/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol new file mode 100644 index 00000000..757f2bae --- /dev/null +++ b/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8; + +import { SetupPartyHelper, Party } from "../utils/SetupPartyHelper.sol"; +import { IERC20 } from "openzeppelin/contracts/interfaces/IERC20.sol"; +import { ERC20Creator, IUniswapV2Router02, IUniswapV2Factory, ITokenDistributor } from "erc20-creator/ERC20Creator.sol"; +import { ERC20LaunchCrowdfund, IERC20Creator } from "contracts/crowdfund/ERC20LaunchCrowdfund.sol"; +import { CrowdfundFactory } from "contracts/crowdfund/CrowdfundFactory.sol"; +import { Vm } from "forge-std/Test.sol"; + +contract ERC20LaunchCrowdfundForkedTest is SetupPartyHelper { + constructor() onlyForked SetupPartyHelper(true) {} + + ERC20Creator internal creator; + ERC20LaunchCrowdfund internal launchCrowdfundImpl; + CrowdfundFactory internal crowdfundFactory; + + function setUp() public override onlyForked { + super.setUp(); + + // Existing addresses on Sepolia + creator = new ERC20Creator( + ITokenDistributor(address(tokenDistributor)), + IUniswapV2Router02(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D), + IUniswapV2Factory(0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f), + 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, + address(0), + 0 + ); + launchCrowdfundImpl = new ERC20LaunchCrowdfund(globals, IERC20Creator(address(creator))); + crowdfundFactory = new CrowdfundFactory(); + } + + function test_ERC20LaunchCrowdfund_happy_path() public onlyForked { + ERC20LaunchCrowdfund.InitialETHCrowdfundOptions memory crowdfundOpts; + ERC20LaunchCrowdfund.ETHPartyOptions memory partyOpts; + ERC20LaunchCrowdfund.ERC20LaunchOptions memory tokenOpts; + + 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); + + crowdfundOpts.maxTotalContributions = 1 ether; + crowdfundOpts.minTotalContributions = 0.001 ether; + crowdfundOpts.exchangeRate = 1 ether; + crowdfundOpts.minContribution = 0.001 ether; + crowdfundOpts.maxContribution = 1 ether; + crowdfundOpts.duration = 1 days; + crowdfundOpts.fundingSplitRecipient = payable(address(this)); + crowdfundOpts.fundingSplitBps = 0.1e4; + + tokenOpts.name = "Test ERC20"; + tokenOpts.symbol = "TEST"; + tokenOpts.totalSupply = 1e6 ether; + tokenOpts.recipient = address(this); + tokenOpts.numTokensForDistribution = 5e4 ether; + tokenOpts.numTokensForRecipient = 5e4 ether; + tokenOpts.numTokensForLP = 9e5 ether; + + ERC20LaunchCrowdfund launchCrowdfund = crowdfundFactory.createERC20LaunchCrowdfund( + launchCrowdfundImpl, + crowdfundOpts, + partyOpts, + tokenOpts, + "" + ); + + address contributor = _randomAddress(); + vm.deal(contributor, 2 ether); + vm.prank(contributor); + vm.recordLogs(); + launchCrowdfund.contribute{ value: 1 ether }(contributor, ""); + launchCrowdfund.launchToken(); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + uint256 balanceBefore = address(this).balance; + launchCrowdfund.sendFundingSplit(); + assertEq(address(this).balance, balanceBefore + 0.1 ether); + + ITokenDistributor.DistributionInfo memory info; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].emitter != address(tokenDistributor)) { + continue; + } + if ( + logs[i].topics[0] != + keccak256( + "DistributionCreated(address,(uint8,uint256,address,address,address,uint128,uint128,uint96))" + ) + ) { + continue; + } + info = abi.decode(logs[i].data, (ITokenDistributor.DistributionInfo)); + } + + vm.prank(contributor); + + bytes memory callData = abi.encodeCall(ITokenDistributor.claim, (info, 1)); + address(tokenDistributor).call(callData); + + assertEq(IERC20(info.token).balanceOf(contributor), 5e4 ether); + assertEq(IERC20(info.token).balanceOf(address(this)), 5e4 ether); + } + + function test_ERC20LaunchCrowdfund_revertIfNumTokensNotAddUpToTotal() public onlyForked { + ERC20LaunchCrowdfund.InitialETHCrowdfundOptions memory crowdfundOpts; + ERC20LaunchCrowdfund.ETHPartyOptions memory partyOpts; + ERC20LaunchCrowdfund.ERC20LaunchOptions memory tokenOpts; + + 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); + + crowdfundOpts.maxTotalContributions = 1 ether; + crowdfundOpts.minTotalContributions = 0.001 ether; + crowdfundOpts.exchangeRate = 1 ether; + crowdfundOpts.minContribution = 0.001 ether; + crowdfundOpts.maxContribution = 1 ether; + crowdfundOpts.duration = 1 days; + crowdfundOpts.fundingSplitRecipient = payable(address(this)); + crowdfundOpts.fundingSplitBps = 0.1e4; + + tokenOpts.name = "Test ERC20"; + tokenOpts.symbol = "TEST"; + tokenOpts.totalSupply = 1e6 ether; + tokenOpts.recipient = address(this); + tokenOpts.numTokensForDistribution = 5e4 ether + 1; // Add 1 to make it invalid + tokenOpts.numTokensForRecipient = 5e4 ether; + tokenOpts.numTokensForLP = 9e5 ether; + + vm.expectRevert(ERC20LaunchCrowdfund.InvalidTokenDistribution.selector); + ERC20LaunchCrowdfund launchCrowdfund = crowdfundFactory.createERC20LaunchCrowdfund( + launchCrowdfundImpl, + crowdfundOpts, + partyOpts, + tokenOpts, + "" + ); + } + + function test_ERC20LaunchCrowdfund_revertIfNumTokensForLPIsTooLow() public onlyForked { + ERC20LaunchCrowdfund.InitialETHCrowdfundOptions memory crowdfundOpts; + ERC20LaunchCrowdfund.ETHPartyOptions memory partyOpts; + ERC20LaunchCrowdfund.ERC20LaunchOptions memory tokenOpts; + + 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); + + crowdfundOpts.maxTotalContributions = 1 ether; + crowdfundOpts.minTotalContributions = 0.001 ether; + crowdfundOpts.exchangeRate = 1 ether; + crowdfundOpts.minContribution = 0.001 ether; + crowdfundOpts.maxContribution = 1 ether; + crowdfundOpts.duration = 1 days; + crowdfundOpts.fundingSplitRecipient = payable(address(this)); + crowdfundOpts.fundingSplitBps = 0.1e4; + + tokenOpts.name = "Test ERC20"; + tokenOpts.symbol = "TEST"; + tokenOpts.totalSupply = 1e6 ether; + tokenOpts.recipient = address(this); + tokenOpts.numTokensForDistribution = 5e4 ether; + tokenOpts.numTokensForRecipient = 5e4 ether; + tokenOpts.numTokensForLP = 1e4 - 1; // Too low + + vm.expectRevert(ERC20LaunchCrowdfund.InvalidTokenDistribution.selector); + ERC20LaunchCrowdfund launchCrowdfund = crowdfundFactory.createERC20LaunchCrowdfund( + launchCrowdfundImpl, + crowdfundOpts, + partyOpts, + tokenOpts, + "" + ); + } + + function test_ERC20LaunchCrowdfund_revertIfFundingSplitBpsTooHigh() public onlyForked { + ERC20LaunchCrowdfund.InitialETHCrowdfundOptions memory crowdfundOpts; + ERC20LaunchCrowdfund.ETHPartyOptions memory partyOpts; + ERC20LaunchCrowdfund.ERC20LaunchOptions memory tokenOpts; + + 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); + + crowdfundOpts.maxTotalContributions = 1 ether; + crowdfundOpts.minTotalContributions = 0.001 ether; + crowdfundOpts.exchangeRate = 1 ether; + crowdfundOpts.minContribution = 0.001 ether; + crowdfundOpts.maxContribution = 1 ether; + crowdfundOpts.duration = 1 days; + crowdfundOpts.fundingSplitRecipient = payable(address(this)); + crowdfundOpts.fundingSplitBps = 0.5e4 + 1; // Too high + + tokenOpts.name = "Test ERC20"; + tokenOpts.symbol = "TEST"; + tokenOpts.totalSupply = 1e6 ether; + tokenOpts.recipient = address(this); + tokenOpts.numTokensForDistribution = 5e4 ether; + tokenOpts.numTokensForRecipient = 5e4 ether; + tokenOpts.numTokensForLP = 9e5 ether; + + vm.expectRevert(ERC20LaunchCrowdfund.InvalidTokenDistribution.selector); + ERC20LaunchCrowdfund launchCrowdfund = crowdfundFactory.createERC20LaunchCrowdfund( + launchCrowdfundImpl, + crowdfundOpts, + partyOpts, + tokenOpts, + "" + ); + } + + function test_ERC20LaunchCrowdfund_canClaimAsLastMember() public onlyForked { + ERC20LaunchCrowdfund.InitialETHCrowdfundOptions memory crowdfundOpts; + ERC20LaunchCrowdfund.ETHPartyOptions memory partyOpts; + ERC20LaunchCrowdfund.ERC20LaunchOptions memory tokenOpts; + + 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); + + crowdfundOpts.maxTotalContributions = 1 ether; + crowdfundOpts.minTotalContributions = 0.001 ether; + crowdfundOpts.exchangeRate = 1 ether; + crowdfundOpts.minContribution = 0.001 ether; + crowdfundOpts.maxContribution = 1 ether; + crowdfundOpts.duration = 1 days; + crowdfundOpts.fundingSplitRecipient = payable(address(this)); + crowdfundOpts.fundingSplitBps = 0.1e4; + + tokenOpts.name = "Test ERC20"; + tokenOpts.symbol = "TEST"; + tokenOpts.totalSupply = 1e6 ether; + tokenOpts.recipient = address(this); + tokenOpts.numTokensForDistribution = 5e4 ether; + tokenOpts.numTokensForRecipient = 5e4 ether; + tokenOpts.numTokensForLP = 9e5 ether; + + ERC20LaunchCrowdfund launchCrowdfund = crowdfundFactory.createERC20LaunchCrowdfund( + launchCrowdfundImpl, + crowdfundOpts, + partyOpts, + tokenOpts, + "" + ); + + address contributor1 = _randomAddress(); + vm.deal(contributor1, 2 ether); + vm.prank(contributor1); + vm.recordLogs(); + launchCrowdfund.contribute{ value: 0.5 ether }(contributor1, ""); + address contributor2 = _randomAddress(); + vm.deal(contributor2, 2 ether); + vm.prank(contributor2); + vm.recordLogs(); + launchCrowdfund.contribute{ value: 0.5 ether }(contributor2, ""); + launchCrowdfund.launchToken(); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + uint256 balanceBefore = address(this).balance; + launchCrowdfund.sendFundingSplit(); + assertEq(address(this).balance, balanceBefore + 0.1 ether); + + ITokenDistributor.DistributionInfo memory info; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].emitter != address(tokenDistributor)) { + continue; + } + if ( + logs[i].topics[0] != + keccak256( + "DistributionCreated(address,(uint8,uint256,address,address,address,uint128,uint128,uint96))" + ) + ) { + continue; + } + info = abi.decode(logs[i].data, (ITokenDistributor.DistributionInfo)); + } + + Party launchParty = launchCrowdfund.party(); + + // Increase total voting power so that maxTokenId check in + // TokenDistributor is triggered + vm.prank(address(launchCrowdfund)); + launchParty.increaseTotalVotingPower(1 ether); + + // Claim as last contributor + vm.prank(contributor2); + bytes memory callData = abi.encodeCall(ITokenDistributor.claim, (info, 2)); + address(tokenDistributor).call(callData); + + assertEq(IERC20(info.token).balanceOf(contributor2), 2.5e4 ether); + } + + function test_ERC20LaunchCrowdfund_finalize() public { + ERC20LaunchCrowdfund.InitialETHCrowdfundOptions memory crowdfundOpts; + ERC20LaunchCrowdfund.ETHPartyOptions memory partyOpts; + ERC20LaunchCrowdfund.ERC20LaunchOptions memory tokenOpts; + + 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); + + crowdfundOpts.maxTotalContributions = 1 ether; + crowdfundOpts.minTotalContributions = 0.001 ether; + crowdfundOpts.exchangeRate = 1 ether; + crowdfundOpts.minContribution = 0.001 ether; + crowdfundOpts.maxContribution = 1 ether; + crowdfundOpts.duration = 1 days; + crowdfundOpts.fundingSplitRecipient = payable(address(this)); + crowdfundOpts.fundingSplitBps = 0.1e4; + + tokenOpts.name = "Test ERC20"; + tokenOpts.symbol = "TEST"; + tokenOpts.totalSupply = 1e6 ether; + tokenOpts.recipient = address(this); + tokenOpts.numTokensForDistribution = 5e4 ether; + tokenOpts.numTokensForRecipient = 5e4 ether; + tokenOpts.numTokensForLP = 9e5 ether; + + ERC20LaunchCrowdfund launchCrowdfund = crowdfundFactory.createERC20LaunchCrowdfund( + launchCrowdfundImpl, + crowdfundOpts, + partyOpts, + tokenOpts, + "" + ); + + address contributor = _randomAddress(); + vm.deal(contributor, 2 ether); + vm.prank(contributor); + vm.recordLogs(); + launchCrowdfund.contribute{ value: 0.5 ether }(contributor, ""); + skip(crowdfundOpts.duration + 1); + launchCrowdfund.finalize(); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + + uint256 balanceBefore = address(this).balance; + launchCrowdfund.sendFundingSplit(); + assertEq(address(this).balance, balanceBefore + 0.05 ether); + + ITokenDistributor.DistributionInfo memory info; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].emitter != address(tokenDistributor)) { + continue; + } + if ( + logs[i].topics[0] != + keccak256( + "DistributionCreated(address,(uint8,uint256,address,address,address,uint128,uint128,uint96))" + ) + ) { + continue; + } + info = abi.decode(logs[i].data, (ITokenDistributor.DistributionInfo)); + } + + vm.prank(contributor); + + bytes memory callData = abi.encodeCall(ITokenDistributor.claim, (info, 1)); + address(tokenDistributor).call(callData); + + assertEq(IERC20(info.token).balanceOf(contributor), 5e4 ether); + assertEq(IERC20(info.token).balanceOf(address(this)), 5e4 ether); + } + + receive() external payable {} +}