From 8866d30f3f05ca1ef9e2a57f50cf41fac1ab2e47 Mon Sep 17 00:00:00 2001 From: Brian Le Date: Wed, 20 Mar 2024 11:33:02 -0400 Subject: [PATCH 01/15] copy code for ERC20 crowdfunds --- contracts/crowdfund/ERC20Crowdfund.sol | 459 +++++++++++++++++++++ contracts/crowdfund/ERC20CrowdfundBase.sol | 437 ++++++++++++++++++++ 2 files changed, 896 insertions(+) create mode 100644 contracts/crowdfund/ERC20Crowdfund.sol create mode 100644 contracts/crowdfund/ERC20CrowdfundBase.sol diff --git a/contracts/crowdfund/ERC20Crowdfund.sol b/contracts/crowdfund/ERC20Crowdfund.sol new file mode 100644 index 00000000..b5ce8020 --- /dev/null +++ b/contracts/crowdfund/ERC20Crowdfund.sol @@ -0,0 +1,459 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.20; + +import { ERC20CrowdfundBase } from "./ERC20CrowdfundBase.sol"; +import { ProposalStorage } from "../proposals/ProposalStorage.sol"; +import { LibAddress } from "../utils/LibAddress.sol"; +import { LibRawResult } from "../utils/LibRawResult.sol"; +import { LibSafeCast } from "../utils/LibSafeCast.sol"; +import { Party, PartyGovernance } from "../party/Party.sol"; +import { Crowdfund } from "../crowdfund/Crowdfund.sol"; +import { MetadataProvider } from "../renderers/MetadataProvider.sol"; +import { IGateKeeper } from "../gatekeepers/IGateKeeper.sol"; +import { IGlobals } from "../globals/IGlobals.sol"; +import { IERC721 } from "../tokens/IERC721.sol"; + +/// @notice A crowdfund for raising the initial funds for new parties. +/// Unlike other crowdfunds that are started for the purpose of +/// acquiring NFT(s), this crowdfund simply bootstraps a party with +/// funds and lets its members coordinate on what to do with it after. +contract ERC20Crowdfund is ERC20CrowdfundBase { + using LibRawResult for bytes; + using LibSafeCast for uint256; + using LibAddress for address payable; + + // Options to be passed into `initialize()` when the crowdfund is created. + struct InitialETHCrowdfundOptions { + address payable initialContributor; + address initialDelegate; + uint96 minContribution; + uint96 maxContribution; + bool disableContributingForExistingCard; + uint96 minTotalContributions; + uint96 maxTotalContributions; + uint160 exchangeRate; + uint16 fundingSplitBps; + address payable fundingSplitRecipient; + uint40 duration; + IGateKeeper gateKeeper; + bytes12 gateKeeperId; + } + + struct ETHPartyOptions { + // Name of the party. + string name; + // Symbol of the party. + string symbol; + // The ID of the customization preset to use for the party card. + uint256 customizationPresetId; + // Options to initialize party governance with. + Crowdfund.FixedGovernanceOpts governanceOpts; + // Options to initialize party proposal engine with. + ProposalStorage.ProposalEngineOpts proposalEngineOpts; + // The tokens that are considered precious by the party.These are + // protected assets and are subject to extra restrictions in proposals + // vs other assets. + IERC721[] preciousTokens; + // The IDs associated with each token in `preciousTokens`. + uint256[] preciousTokenIds; + // The timestamp until which ragequit is enabled. + uint40 rageQuitTimestamp; + // Initial authorities to set on the party + address[] authorities; + } + + struct BatchContributeArgs { + // IDs of cards to credit the contributions to. When set to 0, it means + // a new one should be minted. + uint256[] tokenIds; + // The address to which voting power will be delegated for all + // contributions. This will be ignored if recipient has already set a + // delegate. + address initialDelegate; + // The contribution amounts in wei. The length of this array must be + // equal to the length of `tokenIds`. + uint96[] values; + // The data required to be validated by the `gatekeeper`, if set. If no + // `gatekeeper` is set, this can be empty. + bytes[] gateDatas; + } + + struct BatchContributeForArgs { + // IDs of cards to credit the contributions to. When set to 0, it means + // a new one should be minted. + uint256[] tokenIds; + // Addresses of to credit the contributions under. Each contribution + // amount in `values` corresponds to a recipient in this array. + address payable[] recipients; + // The delegate to set for each recipient. This will be ignored if + // recipient has already set a delegate. + address[] initialDelegates; + // The contribution amounts in wei. The length of this array must be + // equal to the length of `recipients`. + uint96[] values; + // The data required to be validated by the `gatekeeper`, if set. If no + // `gatekeeper` is set, this can be empty. + bytes[] gateDatas; + } + + event Refunded(address indexed contributor, uint256 indexed tokenId, uint256 amount); + + // Set the `Globals` contract. + constructor(IGlobals globals) ERC20CrowdfundBase(globals) {} + + /// @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, + MetadataProvider customMetadataProvider, + bytes memory customMetadata + ) external payable onlyInitialize { + // Create party the initial crowdfund will be for. + Party party_ = _createParty(partyOpts, customMetadataProvider, customMetadata); + + // Initialize the crowdfund. + _initialize( + ETHCrowdfundOptions({ + party: party_, + initialContributor: crowdfundOpts.initialContributor, + initialDelegate: crowdfundOpts.initialDelegate, + minContribution: crowdfundOpts.minContribution, + maxContribution: crowdfundOpts.maxContribution, + disableContributingForExistingCard: crowdfundOpts + .disableContributingForExistingCard, + minTotalContributions: crowdfundOpts.minTotalContributions, + maxTotalContributions: crowdfundOpts.maxTotalContributions, + exchangeRate: crowdfundOpts.exchangeRate, + fundingSplitBps: crowdfundOpts.fundingSplitBps, + fundingSplitRecipient: crowdfundOpts.fundingSplitRecipient, + duration: crowdfundOpts.duration, + gateKeeper: crowdfundOpts.gateKeeper, + gateKeeperId: crowdfundOpts.gateKeeperId + }) + ); + + // If the creator passed in some ETH during initialization, credit them + // for the initial contribution. + uint96 initialContribution = msg.value.safeCastUint256ToUint96(); + if (initialContribution > 0) { + _contribute( + crowdfundOpts.initialContributor, + crowdfundOpts.initialDelegate, + initialContribution, + 0, + "" + ); + } + + // Set up gatekeeper after initial contribution (initial always gets in). + gateKeeper = crowdfundOpts.gateKeeper; + gateKeeperId = crowdfundOpts.gateKeeperId; + } + + /// @notice Contribute ETH to this crowdfund. + /// @param initialDelegate The address to which voting power will be delegated to + /// during the governance phase. This will be ignored + /// if recipient has already set a delegate. + /// @param gateData Data to pass to the gatekeeper to prove eligibility. + /// @return votingPower The voting power the contributor receives for their + /// contribution. + function contribute( + address initialDelegate, + bytes memory gateData + ) public payable onlyDelegateCall returns (uint96 votingPower) { + return + _contribute( + payable(msg.sender), + initialDelegate, + msg.value.safeCastUint256ToUint96(), + 0, // Mint a new party card for the contributor. + gateData + ); + } + + /// @notice Contribute ETH to this crowdfund. + /// @param tokenId The ID of the card the contribution is being made towards. + /// @param initialDelegate The address to which voting power will be delegated to + /// during the governance phase. This will be ignored + /// if recipient has already set a delegate. + /// @param gateData Data to pass to the gatekeeper to prove eligibility. + /// @return votingPower The voting power the contributor receives for their + /// contribution. + function contribute( + uint256 tokenId, + address initialDelegate, + bytes memory gateData + ) public payable onlyDelegateCall returns (uint96 votingPower) { + return + _contribute( + payable(msg.sender), + initialDelegate, + msg.value.safeCastUint256ToUint96(), + tokenId, + gateData + ); + } + + /// @notice `contribute()` in batch form. + /// May not revert if any individual contribution fails. + /// @param args The arguments to pass to each `contribute()` call. + /// @return votingPowers The voting power received for each contribution. + function batchContribute( + BatchContributeArgs calldata args + ) external payable onlyDelegateCall returns (uint96[] memory votingPowers) { + uint256 numContributions = args.tokenIds.length; + + if (numContributions != args.values.length || numContributions != args.gateDatas.length) { + revert ArityMismatch(); + } + + votingPowers = new uint96[](numContributions); + uint256 valuesSum; + + for (uint256 i; i < numContributions; ++i) { + votingPowers[i] = _contribute( + payable(msg.sender), + args.initialDelegate, + args.values[i], + args.tokenIds[i], + args.gateDatas[i] + ); + valuesSum += args.values[i]; + } + if (msg.value != valuesSum) { + revert InvalidMessageValue(); + } + } + + /// @notice Contribute to this crowdfund on behalf of another address. + /// @param tokenId The ID of the token to credit the contribution to, or + /// zero to mint a new party card for the recipient + /// @param recipient The address to record the contribution under + /// @param initialDelegate The address to which voting power will be delegated to + /// during the governance phase. This will be ignored + /// if recipient has already set a delegate. + /// @param gateData Data to pass to the gatekeeper to prove eligibility + /// @return votingPower The voting power received for the contribution + function contributeFor( + uint256 tokenId, + address payable recipient, + address initialDelegate, + bytes memory gateData + ) external payable onlyDelegateCall returns (uint96 votingPower) { + return + _contribute( + recipient, + initialDelegate, + msg.value.safeCastUint256ToUint96(), + tokenId, + gateData + ); + } + + /// @notice `contributeFor()` in batch form. + /// May not revert if any individual contribution fails. + /// @param args The arguments for the batched `contributeFor()` calls. + /// @return votingPowers The voting power received for each contribution. + function batchContributeFor( + BatchContributeForArgs calldata args + ) external payable onlyDelegateCall returns (uint96[] memory votingPowers) { + uint256 numContributions = args.tokenIds.length; + + if ( + numContributions != args.values.length || + numContributions != args.gateDatas.length || + numContributions != args.recipients.length + ) { + revert ArityMismatch(); + } + + votingPowers = new uint96[](numContributions); + uint256 valuesSum; + + for (uint256 i; i < numContributions; ++i) { + votingPowers[i] = _contribute( + args.recipients[i], + args.initialDelegates[i], + args.values[i], + args.tokenIds[i], + args.gateDatas[i] + ); + valuesSum += args.values[i]; + } + if (msg.value != valuesSum) { + revert InvalidMessageValue(); + } + } + + function _contribute( + address payable contributor, + address delegate, + uint96 amount, + uint256 tokenId, + bytes memory gateData + ) private returns (uint96 votingPower) { + // Require a non-null delegate. + if (delegate == address(0)) { + revert InvalidDelegateError(); + } + + // Must not be blocked by gatekeeper. + IGateKeeper _gateKeeper = gateKeeper; + if (_gateKeeper != IGateKeeper(address(0))) { + // Checking msg.sender here instead of contributor is intentional to + // allow someone who's allowed by a gatekeeper to invite others + // into the Party. For example, to allow another contract, and + // only that contract, to process contributions on behalf of + // contributors. + if (!_gateKeeper.isAllowed(msg.sender, gateKeeperId, gateData)) { + revert NotAllowedByGateKeeperError(msg.sender, _gateKeeper, gateKeeperId, gateData); + } + } + + votingPower = _processContribution(contributor, delegate, amount); + + // OK to contribute with zero just to update delegate. + if (amount == 0) return 0; + + if (tokenId == 0) { + // Mint contributor a new party card. + party.mint(contributor, votingPower, delegate); + } else if (disableContributingForExistingCard) { + revert ContributingForExistingCardDisabledError(); + } else if (party.ownerOf(tokenId) == contributor) { + // Increase voting power of contributor's existing party card. + party.increaseVotingPower(tokenId, votingPower); + } else { + revert NotOwnerError(tokenId); + } + } + + /// @notice Refund the owner of a party card and burn it. Only available if + /// the crowdfund lost. Can be called to refund for self or on + /// another's behalf. + /// @param tokenId The ID of the party card to refund the owner of then burn. + /// @return amount The amount of ETH refunded to the contributor. + function refund(uint256 tokenId) external returns (uint96 amount) { + // Check crowdfund lifecycle. + { + CrowdfundLifecycle lc = getCrowdfundLifecycle(); + if (lc != CrowdfundLifecycle.Lost) { + revert WrongLifecycleError(lc); + } + } + + // Get amount to refund. + uint96 votingPower = party.votingPowerByTokenId(tokenId).safeCastUint256ToUint96(); + amount = convertVotingPowerToContribution(votingPower); + + if (amount > 0) { + // Get contributor to refund. + address payable contributor = payable(party.ownerOf(tokenId)); + + // Burn contributor's party card. + party.burn(tokenId); + + // Refund contributor. + contributor.transferEth(amount); + + emit Refunded(contributor, tokenId, amount); + } + } + + /// @notice `refund()` in batch form. + /// May not revert if any individual refund fails. + /// @param tokenIds The IDs of the party cards to burn and refund the owners of. + /// @param revertOnFailure If true, revert if any refund fails. + /// @return amounts The amounts of ETH refunded for each refund. + function batchRefund( + uint256[] calldata tokenIds, + bool revertOnFailure + ) external returns (uint96[] memory amounts) { + uint256 numRefunds = tokenIds.length; + amounts = new uint96[](numRefunds); + + for (uint256 i; i < numRefunds; ++i) { + (bool s, bytes memory r) = address(this).call( + abi.encodeCall(this.refund, (tokenIds[i])) + ); + + if (!s) { + if (revertOnFailure) { + r.rawRevert(); + } + } else { + amounts[i] = abi.decode(r, (uint96)); + } + } + } + + function _createParty( + ETHPartyOptions memory opts, + MetadataProvider customMetadataProvider, + bytes memory customMetadata + ) private returns (Party) { + uint256 authoritiesLength = opts.authorities.length + 1; + address[] memory authorities = new address[](authoritiesLength); + for (uint i = 0; i < authoritiesLength - 1; ++i) { + authorities[i] = opts.authorities[i]; + } + authorities[authoritiesLength - 1] = address(this); + + if (address(customMetadataProvider) == address(0)) { + return + opts.governanceOpts.partyFactory.createParty( + opts.governanceOpts.partyImpl, + authorities, + Party.PartyOptions({ + name: opts.name, + symbol: opts.symbol, + customizationPresetId: opts.customizationPresetId, + governance: PartyGovernance.GovernanceOpts({ + hosts: opts.governanceOpts.hosts, + voteDuration: opts.governanceOpts.voteDuration, + executionDelay: opts.governanceOpts.executionDelay, + passThresholdBps: opts.governanceOpts.passThresholdBps, + totalVotingPower: 0, + feeBps: opts.governanceOpts.feeBps, + feeRecipient: opts.governanceOpts.feeRecipient + }), + proposalEngine: opts.proposalEngineOpts + }), + opts.preciousTokens, + opts.preciousTokenIds, + opts.rageQuitTimestamp + ); + } else { + return + opts.governanceOpts.partyFactory.createPartyWithMetadata( + opts.governanceOpts.partyImpl, + authorities, + Party.PartyOptions({ + name: opts.name, + symbol: opts.symbol, + customizationPresetId: opts.customizationPresetId, + governance: PartyGovernance.GovernanceOpts({ + hosts: opts.governanceOpts.hosts, + voteDuration: opts.governanceOpts.voteDuration, + executionDelay: opts.governanceOpts.executionDelay, + passThresholdBps: opts.governanceOpts.passThresholdBps, + totalVotingPower: 0, + feeBps: opts.governanceOpts.feeBps, + feeRecipient: opts.governanceOpts.feeRecipient + }), + proposalEngine: opts.proposalEngineOpts + }), + opts.preciousTokens, + opts.preciousTokenIds, + opts.rageQuitTimestamp, + customMetadataProvider, + customMetadata + ); + } + } +} diff --git a/contracts/crowdfund/ERC20CrowdfundBase.sol b/contracts/crowdfund/ERC20CrowdfundBase.sol new file mode 100644 index 00000000..2cb80776 --- /dev/null +++ b/contracts/crowdfund/ERC20CrowdfundBase.sol @@ -0,0 +1,437 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.20; + +import "../utils/LibAddress.sol"; +import "../utils/LibSafeCast.sol"; +import "../party/Party.sol"; +import "../gatekeepers/IGateKeeper.sol"; +import { FixedPointMathLib } from "solmate/utils/FixedPointMathLib.sol"; + +abstract contract ERC20CrowdfundBase is Implementation { + using FixedPointMathLib for uint96; + using LibRawResult for bytes; + using LibSafeCast for uint96; + using LibSafeCast for uint256; + using LibAddress for address payable; + + enum CrowdfundLifecycle { + // In practice, this state is never used. If the crowdfund is ever in + // this stage, something is wrong (e.g. crowdfund was never initialized). + Invalid, + // Ready to accept contributions to reach contribution targets + // until a deadline or the minimum contribution target is reached and + // host finalizes. + Active, + // Expired and the minimum contribution target was not reached. + Lost, + // The crowdfund has expired and reached the minimum contribution + // target. It is now ready to finalize. + Won, + // A won crowdfund has been finalized, with funds transferred to the + // party and voting power successfully updated. + Finalized + } + + // Options to be passed into `initialize()` when the crowdfund is created. + struct ETHCrowdfundOptions { + Party party; + address payable initialContributor; + address initialDelegate; + uint96 minContribution; + uint96 maxContribution; + bool disableContributingForExistingCard; + uint96 minTotalContributions; + uint96 maxTotalContributions; + uint160 exchangeRate; + uint16 fundingSplitBps; + address payable fundingSplitRecipient; + uint40 duration; + IGateKeeper gateKeeper; + bytes12 gateKeeperId; + } + + error WrongLifecycleError(CrowdfundLifecycle lc); + error NotAllowedByGateKeeperError( + address contributor, + IGateKeeper gateKeeper, + bytes12 gateKeeperId, + bytes gateData + ); + error OnlyPartyHostError(); + error OnlyPartyDaoError(address notDao); + error OnlyPartyDaoOrHostError(address notDao); + error NotOwnerError(uint256 tokenId); + error OnlyWhenEmergencyActionsAllowedError(); + error InvalidDelegateError(); + error NotEnoughContributionsError(uint96 totalContribution, uint96 minTotalContributions); + error MinGreaterThanMaxError(uint96 min, uint96 max); + error MinMaxDifferenceTooSmall(uint96 min, uint96 max); + error MaxTotalContributionsCannotBeZeroError(uint96 maxTotalContributions); + error BelowMinimumContributionsError(uint96 contributions, uint96 minContributions); + error AboveMaximumContributionsError(uint96 contributions, uint96 maxContributions); + error ExceedsRemainingContributionsError(uint96 amount, uint96 remaining); + error InvalidExchangeRateError(uint160 exchangeRate); + error InvalidFundingSplitRecipient(); + error ContributingForExistingCardDisabledError(); + error ZeroVotingPowerError(); + error FundingSplitAlreadyPaidError(); + error FundingSplitNotConfiguredError(); + error InvalidMessageValue(); + error ArityMismatch(); + + event Contributed( + address indexed sender, + address indexed contributor, + uint256 amount, + address delegate + ); + event Finalized(); + event FundingSplitSent(address indexed fundingSplitRecipient, uint256 amount); + event EmergencyExecuteDisabled(); + event EmergencyExecute(address target, bytes data, uint256 amountEth); + + // The `Globals` contract storing global configuration values. This contract + // is immutable and it’s address will never change. + IGlobals private immutable _GLOBALS; + + /// @notice The address of the `Party` contract instance associated + /// with the crowdfund. + Party public party; + /// @notice The minimum amount of ETH that a contributor can send to + /// participate in the crowdfund. + uint96 public minContribution; + /// @notice The maximum amount of ETH that a contributor can send to + /// participate in the crowdfund per address. + uint96 public maxContribution; + /// @notice A boolean flag that determines whether contributors are allowed + /// to increase the voting power of their existing party cards. + bool public disableContributingForExistingCard; + /// @notice Whether the funding split has been claimed by the funding split + /// recipient. + bool public fundingSplitPaid; + /// @notice Whether the DAO has emergency powers for this crowdfund. + bool public emergencyExecuteDisabled; + /// @notice The minimum amount of total ETH contributions required for the + /// crowdfund to be considered successful. + uint96 public minTotalContributions; + /// @notice The maximum amount of total ETH contributions allowed for the + /// crowdfund. + uint96 public maxTotalContributions; + /// @notice The total amount of ETH contributed to the crowdfund so far. + uint96 public totalContributions; + /// @notice The timestamp at which the crowdfund will end or ended. If 0, the + /// crowdfund has finalized. + uint40 public expiry; + /// @notice The exchange rate from contribution amount to voting power where + /// 100% = 1e18. May be greater than 1e18 (100%). + uint160 public exchangeRate; + /// @notice The portion of contributions to send to the funding recipient in + /// basis points (e.g. 100 = 1%). + uint16 public fundingSplitBps; + /// @notice The address to which a portion of the contributions is sent to. + address payable public fundingSplitRecipient; + /// @notice The gatekeeper contract used to restrict who can contribute to the party. + IGateKeeper public gateKeeper; + /// @notice The ID of the gatekeeper to use for restricting contributions to the party. + bytes12 public gateKeeperId; + /// @notice The address a contributor is delegating their voting power to. + mapping(address => address) public delegationsByContributor; + + // Set the `Globals` contract. + constructor(IGlobals globals) { + _GLOBALS = globals; + } + + // Initialize storage for proxy contract + function _initialize(ETHCrowdfundOptions memory opts) internal { + if (opts.minContribution > opts.maxContribution) { + revert MinGreaterThanMaxError(opts.minContribution, opts.maxContribution); + } + if (opts.maxTotalContributions - opts.minTotalContributions + 1 < opts.minContribution) { + revert MinMaxDifferenceTooSmall(opts.minTotalContributions, opts.maxTotalContributions); + } + // Set the minimum and maximum contribution amounts. + minContribution = opts.minContribution; + maxContribution = opts.maxContribution; + // Set the min total contributions. + 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; + // Set the party crowdfund is for. + party = opts.party; + // Set the crowdfund start and end timestamps. + expiry = (block.timestamp + opts.duration).safeCastUint256ToUint40(); + // Set the exchange rate. + if (opts.exchangeRate == 0) revert InvalidExchangeRateError(opts.exchangeRate); + exchangeRate = opts.exchangeRate; + // Set the funding split and its recipient. + fundingSplitBps = opts.fundingSplitBps; + fundingSplitRecipient = opts.fundingSplitRecipient; + if (opts.fundingSplitBps > 0 && opts.fundingSplitRecipient == address(0)) { + revert InvalidFundingSplitRecipient(); + } + // Set whether to disable contributing for existing card. + disableContributingForExistingCard = opts.disableContributingForExistingCard; + + // Check that the voting power that one receives from a contribution of + // size minContribution is not equal to zero + if (convertContributionToVotingPower(opts.minContribution) == 0) { + revert ZeroVotingPowerError(); + } + } + + /// @notice Get the current lifecycle of the crowdfund. + function getCrowdfundLifecycle() public view returns (CrowdfundLifecycle lifecycle) { + if (maxTotalContributions == 0) { + return CrowdfundLifecycle.Invalid; + } + + uint256 expiry_ = expiry; + if (expiry_ == 0) { + return CrowdfundLifecycle.Finalized; + } + + if (block.timestamp >= expiry_) { + if (totalContributions >= minTotalContributions) { + return CrowdfundLifecycle.Won; + } else { + return CrowdfundLifecycle.Lost; + } + } + + return CrowdfundLifecycle.Active; + } + + function _processContribution( + address payable contributor, + address delegate, + uint96 contribution + ) internal returns (uint96 votingPower) { + address oldDelegate = delegationsByContributor[contributor]; + if (msg.sender == contributor || oldDelegate == address(0)) { + // Update delegate. + delegationsByContributor[contributor] = delegate; + } else { + // Prevent changing another's delegate if already delegated. + delegate = oldDelegate; + } + + emit Contributed(msg.sender, contributor, contribution, delegate); + + // OK to contribute with zero just to update delegate. + if (contribution == 0) return 0; + + // Only allow contributions while the crowdfund is active. + CrowdfundLifecycle lc = getCrowdfundLifecycle(); + if (lc != CrowdfundLifecycle.Active) { + revert WrongLifecycleError(lc); + } + + // Check that the contribution amount is at or above the minimum. + uint96 minContribution_ = minContribution; + if (contribution < minContribution_) { + revert BelowMinimumContributionsError(contribution, minContribution_); + } + + // Check that the contribution amount is at or below the maximum. + uint96 maxContribution_ = maxContribution; + if (contribution > maxContribution_) { + revert AboveMaximumContributionsError(contribution, maxContribution_); + } + + uint96 newTotalContributions = totalContributions + contribution; + uint96 maxTotalContributions_ = maxTotalContributions; + if (newTotalContributions > maxTotalContributions_) { + revert ExceedsRemainingContributionsError( + contribution, + maxTotalContributions_ - totalContributions + ); + } else { + totalContributions = newTotalContributions; + + if ( + maxTotalContributions_ == newTotalContributions || + minContribution_ > maxTotalContributions_ - newTotalContributions + ) { + _finalize(newTotalContributions); + } + } + + // Calculate voting power. + votingPower = convertContributionToVotingPower(contribution); + + if (votingPower == 0) revert ZeroVotingPowerError(); + } + + /// @notice Calculate the voting power amount that would be received from + /// the given contribution. + /// @param contribution The contribution amount. + /// @return votingPower The voting power amount that would be received from + /// the contribution. + function convertContributionToVotingPower( + uint96 contribution + ) public view returns (uint96 votingPower) { + contribution = _removeFundingSplitFromContribution(contribution); + votingPower = _calculateContributionToVotingPower(contribution); + } + + /// @notice Calculate the contribution amount from the given voting power. + /// @param votingPower The voting power to convert to a contribution amount. + /// @return contribution The contribution amount. + function convertVotingPowerToContribution( + uint96 votingPower + ) public view returns (uint96 contribution) { + contribution = _calculateVotingPowerToContribution(votingPower); + contribution = _addFundingSplitToContribution(contribution); + } + + function _calculateContributionToVotingPower( + uint96 contribution + ) private view returns (uint96) { + return contribution.mulDivDown(exchangeRate, 1e18).safeCastUint256ToUint96(); + } + + function _calculateVotingPowerToContribution( + uint96 votingPower + ) internal view returns (uint96) { + return votingPower.mulDivUp(1e18, exchangeRate).safeCastUint256ToUint96(); + } + + function _addFundingSplitToContribution(uint96 contribution) internal view returns (uint96) { + uint16 fundingSplitBps_ = fundingSplitBps; + if (fundingSplitBps_ > 0) { + // Downcast is safe since `contribution` cannot exceed + // type(uint96).max. When the contribution is made, it cannot exceed + // type(uint96).max, neither can `totalContributions` exceed it. + contribution = uint96((uint256(contribution) * 1e4) / (1e4 - fundingSplitBps_)); + } + return contribution; + } + + function _removeFundingSplitFromContribution( + uint96 contribution + ) internal view returns (uint96) { + uint16 fundingSplitBps_ = fundingSplitBps; + if (fundingSplitBps_ > 0) { + // Safe since contribution initially fits into uint96 and cannot get bigger + contribution = uint96((uint256(contribution) * (1e4 - fundingSplitBps_)) / 1e4); + } + return contribution; + } + + function finalize() external { + uint96 totalContributions_ = totalContributions; + + // Check that the crowdfund is not already finalized. + CrowdfundLifecycle lc = getCrowdfundLifecycle(); + if (lc == CrowdfundLifecycle.Active) { + // Allow host to finalize crowdfund early if it has reached its minimum goal. + if (!party.isHost(msg.sender)) revert OnlyPartyHostError(); + + // Check that the crowdfund has reached its minimum goal. + uint96 minTotalContributions_ = minTotalContributions; + if (totalContributions_ < minTotalContributions_) { + revert NotEnoughContributionsError(totalContributions_, minTotalContributions_); + } + } else { + // Otherwise only allow finalization if the crowdfund has expired + // and been won. Can be finalized by anyone. + if (lc != CrowdfundLifecycle.Won) { + revert WrongLifecycleError(lc); + } + } + + // Finalize the crowdfund. + _finalize(totalContributions_); + } + + function _finalize(uint96 totalContributions_) internal { + // 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); + + // Transfer ETH to the party. + payable(address(party)).transferEth(totalContributions_); + + emit Finalized(); + } + + /// @notice Send the funding split to the recipient if applicable. + function sendFundingSplit() external returns (uint96 splitAmount) { + // Check that the crowdfund is finalized. + CrowdfundLifecycle lc = getCrowdfundLifecycle(); + if (lc != CrowdfundLifecycle.Finalized) revert WrongLifecycleError(lc); + + if (fundingSplitPaid) revert FundingSplitAlreadyPaidError(); + + uint16 fundingSplitBps_ = fundingSplitBps; + if (fundingSplitBps_ == 0) { + revert FundingSplitNotConfiguredError(); + } + + fundingSplitPaid = true; + + // Transfer funding split to recipient. + // Assuming fundingSplitBps_ <= 1e4, this cannot overflow uint96 + address payable fundingSplitRecipient_ = fundingSplitRecipient; + splitAmount = uint96((uint256(totalContributions) * fundingSplitBps_) / 1e4); + payable(fundingSplitRecipient_).transferEth(splitAmount); + + emit FundingSplitSent(fundingSplitRecipient_, splitAmount); + } + + /// @notice As the DAO, execute an arbitrary function call from this contract. + /// @dev Emergency actions must not be revoked for this to work. + /// @param targetAddress The contract to call. + /// @param targetCallData The data to pass to the contract. + /// @param amountEth The amount of ETH to send to the contract. + function emergencyExecute( + address targetAddress, + bytes calldata targetCallData, + uint256 amountEth + ) external payable { + // Must be called by the DAO. + if (_GLOBALS.getAddress(LibGlobals.GLOBAL_DAO_WALLET) != msg.sender) { + revert OnlyPartyDaoError(msg.sender); + } + // Must not be disabled by DAO or host. + if (emergencyExecuteDisabled) { + revert OnlyWhenEmergencyActionsAllowedError(); + } + (bool success, bytes memory res) = targetAddress.call{ value: amountEth }(targetCallData); + if (!success) { + res.rawRevert(); + } + emit EmergencyExecute(targetAddress, targetCallData, amountEth); + } + + /// @notice Revoke the DAO's ability to call emergencyExecute(). + /// @dev Either the DAO or the party host can call this. + function disableEmergencyExecute() external { + // Only the DAO or a host can call this. + if ( + !party.isHost(msg.sender) && + _GLOBALS.getAddress(LibGlobals.GLOBAL_DAO_WALLET) != msg.sender + ) { + revert OnlyPartyDaoOrHostError(msg.sender); + } + emergencyExecuteDisabled = true; + emit EmergencyExecuteDisabled(); + } +} From fcaf7807bff772ad6d6de62d5a34f6ae3acc692c Mon Sep 17 00:00:00 2001 From: Brian Le Date: Wed, 20 Mar 2024 11:57:37 -0400 Subject: [PATCH 02/15] update base --- contracts/crowdfund/ERC20CrowdfundBase.sol | 437 ------------------ ...Crowdfund.sol => ERC20LaunchCrowdfund.sol} | 6 +- contracts/crowdfund/ETHCrowdfundBase.sol | 2 +- 3 files changed, 4 insertions(+), 441 deletions(-) delete mode 100644 contracts/crowdfund/ERC20CrowdfundBase.sol rename contracts/crowdfund/{ERC20Crowdfund.sol => ERC20LaunchCrowdfund.sol} (99%) diff --git a/contracts/crowdfund/ERC20CrowdfundBase.sol b/contracts/crowdfund/ERC20CrowdfundBase.sol deleted file mode 100644 index 2cb80776..00000000 --- a/contracts/crowdfund/ERC20CrowdfundBase.sol +++ /dev/null @@ -1,437 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.20; - -import "../utils/LibAddress.sol"; -import "../utils/LibSafeCast.sol"; -import "../party/Party.sol"; -import "../gatekeepers/IGateKeeper.sol"; -import { FixedPointMathLib } from "solmate/utils/FixedPointMathLib.sol"; - -abstract contract ERC20CrowdfundBase is Implementation { - using FixedPointMathLib for uint96; - using LibRawResult for bytes; - using LibSafeCast for uint96; - using LibSafeCast for uint256; - using LibAddress for address payable; - - enum CrowdfundLifecycle { - // In practice, this state is never used. If the crowdfund is ever in - // this stage, something is wrong (e.g. crowdfund was never initialized). - Invalid, - // Ready to accept contributions to reach contribution targets - // until a deadline or the minimum contribution target is reached and - // host finalizes. - Active, - // Expired and the minimum contribution target was not reached. - Lost, - // The crowdfund has expired and reached the minimum contribution - // target. It is now ready to finalize. - Won, - // A won crowdfund has been finalized, with funds transferred to the - // party and voting power successfully updated. - Finalized - } - - // Options to be passed into `initialize()` when the crowdfund is created. - struct ETHCrowdfundOptions { - Party party; - address payable initialContributor; - address initialDelegate; - uint96 minContribution; - uint96 maxContribution; - bool disableContributingForExistingCard; - uint96 minTotalContributions; - uint96 maxTotalContributions; - uint160 exchangeRate; - uint16 fundingSplitBps; - address payable fundingSplitRecipient; - uint40 duration; - IGateKeeper gateKeeper; - bytes12 gateKeeperId; - } - - error WrongLifecycleError(CrowdfundLifecycle lc); - error NotAllowedByGateKeeperError( - address contributor, - IGateKeeper gateKeeper, - bytes12 gateKeeperId, - bytes gateData - ); - error OnlyPartyHostError(); - error OnlyPartyDaoError(address notDao); - error OnlyPartyDaoOrHostError(address notDao); - error NotOwnerError(uint256 tokenId); - error OnlyWhenEmergencyActionsAllowedError(); - error InvalidDelegateError(); - error NotEnoughContributionsError(uint96 totalContribution, uint96 minTotalContributions); - error MinGreaterThanMaxError(uint96 min, uint96 max); - error MinMaxDifferenceTooSmall(uint96 min, uint96 max); - error MaxTotalContributionsCannotBeZeroError(uint96 maxTotalContributions); - error BelowMinimumContributionsError(uint96 contributions, uint96 minContributions); - error AboveMaximumContributionsError(uint96 contributions, uint96 maxContributions); - error ExceedsRemainingContributionsError(uint96 amount, uint96 remaining); - error InvalidExchangeRateError(uint160 exchangeRate); - error InvalidFundingSplitRecipient(); - error ContributingForExistingCardDisabledError(); - error ZeroVotingPowerError(); - error FundingSplitAlreadyPaidError(); - error FundingSplitNotConfiguredError(); - error InvalidMessageValue(); - error ArityMismatch(); - - event Contributed( - address indexed sender, - address indexed contributor, - uint256 amount, - address delegate - ); - event Finalized(); - event FundingSplitSent(address indexed fundingSplitRecipient, uint256 amount); - event EmergencyExecuteDisabled(); - event EmergencyExecute(address target, bytes data, uint256 amountEth); - - // The `Globals` contract storing global configuration values. This contract - // is immutable and it’s address will never change. - IGlobals private immutable _GLOBALS; - - /// @notice The address of the `Party` contract instance associated - /// with the crowdfund. - Party public party; - /// @notice The minimum amount of ETH that a contributor can send to - /// participate in the crowdfund. - uint96 public minContribution; - /// @notice The maximum amount of ETH that a contributor can send to - /// participate in the crowdfund per address. - uint96 public maxContribution; - /// @notice A boolean flag that determines whether contributors are allowed - /// to increase the voting power of their existing party cards. - bool public disableContributingForExistingCard; - /// @notice Whether the funding split has been claimed by the funding split - /// recipient. - bool public fundingSplitPaid; - /// @notice Whether the DAO has emergency powers for this crowdfund. - bool public emergencyExecuteDisabled; - /// @notice The minimum amount of total ETH contributions required for the - /// crowdfund to be considered successful. - uint96 public minTotalContributions; - /// @notice The maximum amount of total ETH contributions allowed for the - /// crowdfund. - uint96 public maxTotalContributions; - /// @notice The total amount of ETH contributed to the crowdfund so far. - uint96 public totalContributions; - /// @notice The timestamp at which the crowdfund will end or ended. If 0, the - /// crowdfund has finalized. - uint40 public expiry; - /// @notice The exchange rate from contribution amount to voting power where - /// 100% = 1e18. May be greater than 1e18 (100%). - uint160 public exchangeRate; - /// @notice The portion of contributions to send to the funding recipient in - /// basis points (e.g. 100 = 1%). - uint16 public fundingSplitBps; - /// @notice The address to which a portion of the contributions is sent to. - address payable public fundingSplitRecipient; - /// @notice The gatekeeper contract used to restrict who can contribute to the party. - IGateKeeper public gateKeeper; - /// @notice The ID of the gatekeeper to use for restricting contributions to the party. - bytes12 public gateKeeperId; - /// @notice The address a contributor is delegating their voting power to. - mapping(address => address) public delegationsByContributor; - - // Set the `Globals` contract. - constructor(IGlobals globals) { - _GLOBALS = globals; - } - - // Initialize storage for proxy contract - function _initialize(ETHCrowdfundOptions memory opts) internal { - if (opts.minContribution > opts.maxContribution) { - revert MinGreaterThanMaxError(opts.minContribution, opts.maxContribution); - } - if (opts.maxTotalContributions - opts.minTotalContributions + 1 < opts.minContribution) { - revert MinMaxDifferenceTooSmall(opts.minTotalContributions, opts.maxTotalContributions); - } - // Set the minimum and maximum contribution amounts. - minContribution = opts.minContribution; - maxContribution = opts.maxContribution; - // Set the min total contributions. - 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; - // Set the party crowdfund is for. - party = opts.party; - // Set the crowdfund start and end timestamps. - expiry = (block.timestamp + opts.duration).safeCastUint256ToUint40(); - // Set the exchange rate. - if (opts.exchangeRate == 0) revert InvalidExchangeRateError(opts.exchangeRate); - exchangeRate = opts.exchangeRate; - // Set the funding split and its recipient. - fundingSplitBps = opts.fundingSplitBps; - fundingSplitRecipient = opts.fundingSplitRecipient; - if (opts.fundingSplitBps > 0 && opts.fundingSplitRecipient == address(0)) { - revert InvalidFundingSplitRecipient(); - } - // Set whether to disable contributing for existing card. - disableContributingForExistingCard = opts.disableContributingForExistingCard; - - // Check that the voting power that one receives from a contribution of - // size minContribution is not equal to zero - if (convertContributionToVotingPower(opts.minContribution) == 0) { - revert ZeroVotingPowerError(); - } - } - - /// @notice Get the current lifecycle of the crowdfund. - function getCrowdfundLifecycle() public view returns (CrowdfundLifecycle lifecycle) { - if (maxTotalContributions == 0) { - return CrowdfundLifecycle.Invalid; - } - - uint256 expiry_ = expiry; - if (expiry_ == 0) { - return CrowdfundLifecycle.Finalized; - } - - if (block.timestamp >= expiry_) { - if (totalContributions >= minTotalContributions) { - return CrowdfundLifecycle.Won; - } else { - return CrowdfundLifecycle.Lost; - } - } - - return CrowdfundLifecycle.Active; - } - - function _processContribution( - address payable contributor, - address delegate, - uint96 contribution - ) internal returns (uint96 votingPower) { - address oldDelegate = delegationsByContributor[contributor]; - if (msg.sender == contributor || oldDelegate == address(0)) { - // Update delegate. - delegationsByContributor[contributor] = delegate; - } else { - // Prevent changing another's delegate if already delegated. - delegate = oldDelegate; - } - - emit Contributed(msg.sender, contributor, contribution, delegate); - - // OK to contribute with zero just to update delegate. - if (contribution == 0) return 0; - - // Only allow contributions while the crowdfund is active. - CrowdfundLifecycle lc = getCrowdfundLifecycle(); - if (lc != CrowdfundLifecycle.Active) { - revert WrongLifecycleError(lc); - } - - // Check that the contribution amount is at or above the minimum. - uint96 minContribution_ = minContribution; - if (contribution < minContribution_) { - revert BelowMinimumContributionsError(contribution, minContribution_); - } - - // Check that the contribution amount is at or below the maximum. - uint96 maxContribution_ = maxContribution; - if (contribution > maxContribution_) { - revert AboveMaximumContributionsError(contribution, maxContribution_); - } - - uint96 newTotalContributions = totalContributions + contribution; - uint96 maxTotalContributions_ = maxTotalContributions; - if (newTotalContributions > maxTotalContributions_) { - revert ExceedsRemainingContributionsError( - contribution, - maxTotalContributions_ - totalContributions - ); - } else { - totalContributions = newTotalContributions; - - if ( - maxTotalContributions_ == newTotalContributions || - minContribution_ > maxTotalContributions_ - newTotalContributions - ) { - _finalize(newTotalContributions); - } - } - - // Calculate voting power. - votingPower = convertContributionToVotingPower(contribution); - - if (votingPower == 0) revert ZeroVotingPowerError(); - } - - /// @notice Calculate the voting power amount that would be received from - /// the given contribution. - /// @param contribution The contribution amount. - /// @return votingPower The voting power amount that would be received from - /// the contribution. - function convertContributionToVotingPower( - uint96 contribution - ) public view returns (uint96 votingPower) { - contribution = _removeFundingSplitFromContribution(contribution); - votingPower = _calculateContributionToVotingPower(contribution); - } - - /// @notice Calculate the contribution amount from the given voting power. - /// @param votingPower The voting power to convert to a contribution amount. - /// @return contribution The contribution amount. - function convertVotingPowerToContribution( - uint96 votingPower - ) public view returns (uint96 contribution) { - contribution = _calculateVotingPowerToContribution(votingPower); - contribution = _addFundingSplitToContribution(contribution); - } - - function _calculateContributionToVotingPower( - uint96 contribution - ) private view returns (uint96) { - return contribution.mulDivDown(exchangeRate, 1e18).safeCastUint256ToUint96(); - } - - function _calculateVotingPowerToContribution( - uint96 votingPower - ) internal view returns (uint96) { - return votingPower.mulDivUp(1e18, exchangeRate).safeCastUint256ToUint96(); - } - - function _addFundingSplitToContribution(uint96 contribution) internal view returns (uint96) { - uint16 fundingSplitBps_ = fundingSplitBps; - if (fundingSplitBps_ > 0) { - // Downcast is safe since `contribution` cannot exceed - // type(uint96).max. When the contribution is made, it cannot exceed - // type(uint96).max, neither can `totalContributions` exceed it. - contribution = uint96((uint256(contribution) * 1e4) / (1e4 - fundingSplitBps_)); - } - return contribution; - } - - function _removeFundingSplitFromContribution( - uint96 contribution - ) internal view returns (uint96) { - uint16 fundingSplitBps_ = fundingSplitBps; - if (fundingSplitBps_ > 0) { - // Safe since contribution initially fits into uint96 and cannot get bigger - contribution = uint96((uint256(contribution) * (1e4 - fundingSplitBps_)) / 1e4); - } - return contribution; - } - - function finalize() external { - uint96 totalContributions_ = totalContributions; - - // Check that the crowdfund is not already finalized. - CrowdfundLifecycle lc = getCrowdfundLifecycle(); - if (lc == CrowdfundLifecycle.Active) { - // Allow host to finalize crowdfund early if it has reached its minimum goal. - if (!party.isHost(msg.sender)) revert OnlyPartyHostError(); - - // Check that the crowdfund has reached its minimum goal. - uint96 minTotalContributions_ = minTotalContributions; - if (totalContributions_ < minTotalContributions_) { - revert NotEnoughContributionsError(totalContributions_, minTotalContributions_); - } - } else { - // Otherwise only allow finalization if the crowdfund has expired - // and been won. Can be finalized by anyone. - if (lc != CrowdfundLifecycle.Won) { - revert WrongLifecycleError(lc); - } - } - - // Finalize the crowdfund. - _finalize(totalContributions_); - } - - function _finalize(uint96 totalContributions_) internal { - // 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); - - // Transfer ETH to the party. - payable(address(party)).transferEth(totalContributions_); - - emit Finalized(); - } - - /// @notice Send the funding split to the recipient if applicable. - function sendFundingSplit() external returns (uint96 splitAmount) { - // Check that the crowdfund is finalized. - CrowdfundLifecycle lc = getCrowdfundLifecycle(); - if (lc != CrowdfundLifecycle.Finalized) revert WrongLifecycleError(lc); - - if (fundingSplitPaid) revert FundingSplitAlreadyPaidError(); - - uint16 fundingSplitBps_ = fundingSplitBps; - if (fundingSplitBps_ == 0) { - revert FundingSplitNotConfiguredError(); - } - - fundingSplitPaid = true; - - // Transfer funding split to recipient. - // Assuming fundingSplitBps_ <= 1e4, this cannot overflow uint96 - address payable fundingSplitRecipient_ = fundingSplitRecipient; - splitAmount = uint96((uint256(totalContributions) * fundingSplitBps_) / 1e4); - payable(fundingSplitRecipient_).transferEth(splitAmount); - - emit FundingSplitSent(fundingSplitRecipient_, splitAmount); - } - - /// @notice As the DAO, execute an arbitrary function call from this contract. - /// @dev Emergency actions must not be revoked for this to work. - /// @param targetAddress The contract to call. - /// @param targetCallData The data to pass to the contract. - /// @param amountEth The amount of ETH to send to the contract. - function emergencyExecute( - address targetAddress, - bytes calldata targetCallData, - uint256 amountEth - ) external payable { - // Must be called by the DAO. - if (_GLOBALS.getAddress(LibGlobals.GLOBAL_DAO_WALLET) != msg.sender) { - revert OnlyPartyDaoError(msg.sender); - } - // Must not be disabled by DAO or host. - if (emergencyExecuteDisabled) { - revert OnlyWhenEmergencyActionsAllowedError(); - } - (bool success, bytes memory res) = targetAddress.call{ value: amountEth }(targetCallData); - if (!success) { - res.rawRevert(); - } - emit EmergencyExecute(targetAddress, targetCallData, amountEth); - } - - /// @notice Revoke the DAO's ability to call emergencyExecute(). - /// @dev Either the DAO or the party host can call this. - function disableEmergencyExecute() external { - // Only the DAO or a host can call this. - if ( - !party.isHost(msg.sender) && - _GLOBALS.getAddress(LibGlobals.GLOBAL_DAO_WALLET) != msg.sender - ) { - revert OnlyPartyDaoOrHostError(msg.sender); - } - emergencyExecuteDisabled = true; - emit EmergencyExecuteDisabled(); - } -} diff --git a/contracts/crowdfund/ERC20Crowdfund.sol b/contracts/crowdfund/ERC20LaunchCrowdfund.sol similarity index 99% rename from contracts/crowdfund/ERC20Crowdfund.sol rename to contracts/crowdfund/ERC20LaunchCrowdfund.sol index b5ce8020..e5276ebe 100644 --- a/contracts/crowdfund/ERC20Crowdfund.sol +++ b/contracts/crowdfund/ERC20LaunchCrowdfund.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.20; -import { ERC20CrowdfundBase } from "./ERC20CrowdfundBase.sol"; +import { ETHCrowdfundBase } from "./ETHCrowdfundBase.sol"; import { ProposalStorage } from "../proposals/ProposalStorage.sol"; import { LibAddress } from "../utils/LibAddress.sol"; import { LibRawResult } from "../utils/LibRawResult.sol"; @@ -17,7 +17,7 @@ import { IERC721 } from "../tokens/IERC721.sol"; /// Unlike other crowdfunds that are started for the purpose of /// acquiring NFT(s), this crowdfund simply bootstraps a party with /// funds and lets its members coordinate on what to do with it after. -contract ERC20Crowdfund is ERC20CrowdfundBase { +contract ERC20LaunchCrowdfund is ETHCrowdfundBase { using LibRawResult for bytes; using LibSafeCast for uint256; using LibAddress for address payable; @@ -99,7 +99,7 @@ contract ERC20Crowdfund is ERC20CrowdfundBase { event Refunded(address indexed contributor, uint256 indexed tokenId, uint256 amount); // Set the `Globals` contract. - constructor(IGlobals globals) ERC20CrowdfundBase(globals) {} + constructor(IGlobals globals) ETHCrowdfundBase(globals) {} /// @notice Initializer to be called prior to using the contract. /// @param crowdfundOpts Options to initialize the crowdfund with. diff --git a/contracts/crowdfund/ETHCrowdfundBase.sol b/contracts/crowdfund/ETHCrowdfundBase.sol index a63c7495..78a1bf71 100644 --- a/contracts/crowdfund/ETHCrowdfundBase.sol +++ b/contracts/crowdfund/ETHCrowdfundBase.sol @@ -351,7 +351,7 @@ abstract contract ETHCrowdfundBase is Implementation { _finalize(totalContributions_); } - function _finalize(uint96 totalContributions_) internal { + function _finalize(uint96 totalContributions_) internal virtual { // Finalize the crowdfund. delete expiry; From bdd51d788916339a6b3a2fffb6b945eb187a42f5 Mon Sep 17 00:00:00 2001 From: Brian Le Date: Wed, 20 Mar 2024 14:32:30 -0400 Subject: [PATCH 03/15] WIP --- .gitmodules | 6 + contracts/crowdfund/ERC20LaunchCrowdfund.sol | 126 ++++++++++++++++++- contracts/crowdfund/ETHCrowdfundBase.sol | 2 +- contracts/tokens/GovernableERC20.sol | 35 ++++++ lib/forge-std | 2 +- lib/party-addresses | 2 +- lib/v2-core | 1 + lib/v2-periphery | 1 + remappings.txt | 6 +- 9 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 contracts/tokens/GovernableERC20.sol create mode 160000 lib/v2-core create mode 160000 lib/v2-periphery diff --git a/.gitmodules b/.gitmodules index dc951e5b..1b2a2aa2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,9 @@ [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 diff --git a/contracts/crowdfund/ERC20LaunchCrowdfund.sol b/contracts/crowdfund/ERC20LaunchCrowdfund.sol index e5276ebe..9227ff24 100644 --- a/contracts/crowdfund/ERC20LaunchCrowdfund.sol +++ b/contracts/crowdfund/ERC20LaunchCrowdfund.sol @@ -9,9 +9,13 @@ import { LibSafeCast } from "../utils/LibSafeCast.sol"; import { Party, PartyGovernance } from "../party/Party.sol"; import { Crowdfund } from "../crowdfund/Crowdfund.sol"; import { MetadataProvider } from "../renderers/MetadataProvider.sol"; +import { GovernableERC20, ERC20 } from "../tokens/GovernableERC20.sol"; import { IGateKeeper } from "../gatekeepers/IGateKeeper.sol"; import { IGlobals } from "../globals/IGlobals.sol"; import { IERC721 } from "../tokens/IERC721.sol"; +import { ITokenDistributor, IERC20 } from "../distribution/ITokenDistributor.sol"; +import { IUniswapV2Router02 } from "uniswap-v2-periphery/interfaces/IUniswapV2Router02.sol"; +import { IUniswapV2Factory } from "uniswap-v2-core/interfaces/IUniswapV2Factory.sol"; /// @notice A crowdfund for raising the initial funds for new parties. /// Unlike other crowdfunds that are started for the purpose of @@ -62,6 +66,22 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { address[] authorities; } + // TODO: Add comments + // TODO: Pack storage? + struct ERC20LaunchOptions { + // The name of the ERC20 token launched. + string name; + // The symbol of the ERC20 token launched. + string symbol; + // The total supply to mint for the ERC20 token. + 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; + address recipient; + } + struct BatchContributeArgs { // IDs of cards to credit the contributions to. When set to 0, it means // a new one should be minted. @@ -97,9 +117,36 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { } event Refunded(address indexed contributor, uint256 indexed tokenId, uint256 amount); - - // Set the `Globals` contract. - constructor(IGlobals globals) ETHCrowdfundBase(globals) {} + event ERC20Created(ERC20 indexed token, Party indexed party, ERC20LaunchOptions opts); + + error InvalidTokenDistribution(); + + // TODO: Pack storage? + uint16 public immutable FEE_BPS; + address payable public immutable FEE_RECIPIENT; + ITokenDistributor public immutable TOKEN_DISTRIBUTOR; + IUniswapV2Router02 public immutable UNISWAP_V2_ROUTER; + IUniswapV2Factory public immutable UNISWAP_V2_FACTORY; + address public immutable WETH; + + ERC20LaunchOptions public tokenOpts; + + constructor( + IGlobals globals, + uint16 feeBps, + address payable feeRecipient, + ITokenDistributor tokenDistributor, + IUniswapV2Router02 uniswapV2Router, + IUniswapV2Factory uniswapV2Factory, + address weth + ) ETHCrowdfundBase(globals) { + FEE_BPS = feeBps; + FEE_RECIPIENT = feeRecipient; + TOKEN_DISTRIBUTOR = tokenDistributor; + UNISWAP_V2_ROUTER = uniswapV2Router; + UNISWAP_V2_FACTORY = uniswapV2Factory; + WETH = weth; + } /// @notice Initializer to be called prior to using the contract. /// @param crowdfundOpts Options to initialize the crowdfund with. @@ -110,9 +157,21 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { function initialize( InitialETHCrowdfundOptions memory crowdfundOpts, ETHPartyOptions memory partyOpts, + ERC20LaunchOptions memory _tokenOpts, MetadataProvider customMetadataProvider, bytes memory customMetadata ) external payable onlyInitialize { + if ( + _tokenOpts.numTokensForDistribution + + _tokenOpts.numTokensForRecipient + + _tokenOpts.numTokensForLP != + _tokenOpts.totalSupply + ) { + revert InvalidTokenDistribution(); + } + + tokenOpts = _tokenOpts; + // Create party the initial crowdfund will be for. Party party_ = _createParty(partyOpts, customMetadataProvider, customMetadata); @@ -456,4 +515,65 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { ); } } + + function _finalize(uint96 totalContributions_) internal override { + Party _party = party; + + // 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(); + + ERC20LaunchOptions memory _tokenOpts = tokenOpts; + + // Create token + ERC20 token = new GovernableERC20( + _tokenOpts.name, + _tokenOpts.symbol, + _tokenOpts.totalSupply, + address(this) + ); + + // Create distribution + token.transfer(address(TOKEN_DISTRIBUTOR), _tokenOpts.numTokensForDistribution); + TOKEN_DISTRIBUTOR.createErc20Distribution( + IERC20(address(token)), + _party, + payable(address(0)), + 0 + ); + + // Take fee + uint256 ethValue = msg.value; + uint256 feeAmount = (ethValue * FEE_BPS) / 1e4; + payable(FEE_RECIPIENT).transfer(feeAmount); + + // Create locked LP pair + uint256 numETHForLP = ethValue - feeAmount; + token.approve(address(UNISWAP_V2_ROUTER), _tokenOpts.numTokensForLP); + UNISWAP_V2_ROUTER.addLiquidityETH{ value: numETHForLP }( + address(token), + _tokenOpts.numTokensForLP, + _tokenOpts.numTokensForLP, + numETHForLP, + address(0), // Burn LP position + block.timestamp + 10 minutes + ); + + // Transfer tokens to recipient + token.transfer(_tokenOpts.recipient, _tokenOpts.numTokensForRecipient); + + emit ERC20Created(token, _party, _tokenOpts); + } } diff --git a/contracts/crowdfund/ETHCrowdfundBase.sol b/contracts/crowdfund/ETHCrowdfundBase.sol index 78a1bf71..8e4dc84c 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(); } diff --git a/contracts/tokens/GovernableERC20.sol b/contracts/tokens/GovernableERC20.sol new file mode 100644 index 00000000..7f65fad9 --- /dev/null +++ b/contracts/tokens/GovernableERC20.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8; + +import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Votes.sol"; + +contract GovernableERC20 is ERC20, ERC20Permit, ERC20Votes { + constructor( + string memory _name, + string memory _symbol, + uint256 _totalSupply, + address _receiver + ) ERC20(_name, _symbol) ERC20Permit(_name) { + _mint(_receiver, _totalSupply); + } + + // Default to self-delegation if no delegate is set. This enables snapshots + // to work as expected, otherwise when transferring votes to undelegated addresses + // the votes would not be moved (see `Votes._moveDelegateVotes`). + function delegates(address account) public view override returns (address) { + address delegate = super.delegates(account); + return delegate == address(0) ? account : delegate; + } + + // The following functions are overrides required by Solidity. + + function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) { + super._update(from, to, value); + } + + function nonces(address owner) public view override(ERC20Permit, Nonces) returns (uint256) { + return super.nonces(owner); + } +} 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..60096651 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,3 +1,7 @@ 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/ \ No newline at end of file From 771648fd89cf5a5ee0fdf36c3ee076be9a2b3de1 Mon Sep 17 00:00:00 2001 From: Brian Le Date: Wed, 20 Mar 2024 14:33:36 -0400 Subject: [PATCH 04/15] add missing total supply --- contracts/crowdfund/ERC20LaunchCrowdfund.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/crowdfund/ERC20LaunchCrowdfund.sol b/contracts/crowdfund/ERC20LaunchCrowdfund.sol index 9227ff24..3b63f025 100644 --- a/contracts/crowdfund/ERC20LaunchCrowdfund.sol +++ b/contracts/crowdfund/ERC20LaunchCrowdfund.sol @@ -74,6 +74,8 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { // The symbol of the ERC20 token launched. string symbol; // 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; From 331485bfa7166bcf05c120dc238b9acbc3e7a617 Mon Sep 17 00:00:00 2001 From: Brian Le Date: Wed, 20 Mar 2024 14:49:53 -0400 Subject: [PATCH 05/15] refactor to call ERC20Creator --- contracts/crowdfund/ERC20LaunchCrowdfund.sol | 81 ++++---------------- contracts/utils/IERC20Creator.sol | 21 +++++ 2 files changed, 37 insertions(+), 65 deletions(-) create mode 100644 contracts/utils/IERC20Creator.sol diff --git a/contracts/crowdfund/ERC20LaunchCrowdfund.sol b/contracts/crowdfund/ERC20LaunchCrowdfund.sol index 3b63f025..2dfaf614 100644 --- a/contracts/crowdfund/ERC20LaunchCrowdfund.sol +++ b/contracts/crowdfund/ERC20LaunchCrowdfund.sol @@ -9,13 +9,10 @@ import { LibSafeCast } from "../utils/LibSafeCast.sol"; import { Party, PartyGovernance } from "../party/Party.sol"; import { Crowdfund } from "../crowdfund/Crowdfund.sol"; import { MetadataProvider } from "../renderers/MetadataProvider.sol"; -import { GovernableERC20, ERC20 } from "../tokens/GovernableERC20.sol"; import { IGateKeeper } from "../gatekeepers/IGateKeeper.sol"; import { IGlobals } from "../globals/IGlobals.sol"; import { IERC721 } from "../tokens/IERC721.sol"; -import { ITokenDistributor, IERC20 } from "../distribution/ITokenDistributor.sol"; -import { IUniswapV2Router02 } from "uniswap-v2-periphery/interfaces/IUniswapV2Router02.sol"; -import { IUniswapV2Factory } from "uniswap-v2-core/interfaces/IUniswapV2Factory.sol"; +import { IERC20Creator, TokenConfiguration } from "../utils/IERC20Creator.sol"; /// @notice A crowdfund for raising the initial funds for new parties. /// Unlike other crowdfunds that are started for the purpose of @@ -66,13 +63,13 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { address[] authorities; } - // TODO: Add comments - // TODO: Pack storage? 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. @@ -81,7 +78,6 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { uint256 numTokensForRecipient; // The number of tokens to use for the Uniswap LP pair. uint256 numTokensForLP; - address recipient; } struct BatchContributeArgs { @@ -119,35 +115,15 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { } event Refunded(address indexed contributor, uint256 indexed tokenId, uint256 amount); - event ERC20Created(ERC20 indexed token, Party indexed party, ERC20LaunchOptions opts); error InvalidTokenDistribution(); - // TODO: Pack storage? - uint16 public immutable FEE_BPS; - address payable public immutable FEE_RECIPIENT; - ITokenDistributor public immutable TOKEN_DISTRIBUTOR; - IUniswapV2Router02 public immutable UNISWAP_V2_ROUTER; - IUniswapV2Factory public immutable UNISWAP_V2_FACTORY; - address public immutable WETH; + IERC20Creator public immutable ERC20_CREATOR; ERC20LaunchOptions public tokenOpts; - constructor( - IGlobals globals, - uint16 feeBps, - address payable feeRecipient, - ITokenDistributor tokenDistributor, - IUniswapV2Router02 uniswapV2Router, - IUniswapV2Factory uniswapV2Factory, - address weth - ) ETHCrowdfundBase(globals) { - FEE_BPS = feeBps; - FEE_RECIPIENT = feeRecipient; - TOKEN_DISTRIBUTOR = tokenDistributor; - UNISWAP_V2_ROUTER = uniswapV2Router; - UNISWAP_V2_FACTORY = uniswapV2Factory; - WETH = weth; + constructor(IGlobals globals, IERC20Creator erc20Creator) ETHCrowdfundBase(globals) { + ERC20_CREATOR = erc20Creator; } /// @notice Initializer to be called prior to using the contract. @@ -539,43 +515,18 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { ERC20LaunchOptions memory _tokenOpts = tokenOpts; - // Create token - ERC20 token = new GovernableERC20( + // Create the ERC20 token. + ERC20_CREATOR.createToken( + address(_party), _tokenOpts.name, _tokenOpts.symbol, - _tokenOpts.totalSupply, - address(this) - ); - - // Create distribution - token.transfer(address(TOKEN_DISTRIBUTOR), _tokenOpts.numTokensForDistribution); - TOKEN_DISTRIBUTOR.createErc20Distribution( - IERC20(address(token)), - _party, - payable(address(0)), - 0 + TokenConfiguration({ + totalSupply: _tokenOpts.totalSupply, + numTokensForDistribution: _tokenOpts.numTokensForDistribution, + numTokensForRecipient: _tokenOpts.numTokensForRecipient, + numTokensForLP: _tokenOpts.numTokensForLP + }), + _tokenOpts.recipient ); - - // Take fee - uint256 ethValue = msg.value; - uint256 feeAmount = (ethValue * FEE_BPS) / 1e4; - payable(FEE_RECIPIENT).transfer(feeAmount); - - // Create locked LP pair - uint256 numETHForLP = ethValue - feeAmount; - token.approve(address(UNISWAP_V2_ROUTER), _tokenOpts.numTokensForLP); - UNISWAP_V2_ROUTER.addLiquidityETH{ value: numETHForLP }( - address(token), - _tokenOpts.numTokensForLP, - _tokenOpts.numTokensForLP, - numETHForLP, - address(0), // Burn LP position - block.timestamp + 10 minutes - ); - - // Transfer tokens to recipient - token.transfer(_tokenOpts.recipient, _tokenOpts.numTokensForRecipient); - - emit ERC20Created(token, _party, _tokenOpts); } } 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); +} From 36529094290c3d7c875f0d5fa4c9073ae382e756 Mon Sep 17 00:00:00 2001 From: Brian Le Date: Wed, 20 Mar 2024 18:02:53 -0400 Subject: [PATCH 06/15] send ETH along --- contracts/crowdfund/ERC20LaunchCrowdfund.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crowdfund/ERC20LaunchCrowdfund.sol b/contracts/crowdfund/ERC20LaunchCrowdfund.sol index 2dfaf614..2905338e 100644 --- a/contracts/crowdfund/ERC20LaunchCrowdfund.sol +++ b/contracts/crowdfund/ERC20LaunchCrowdfund.sol @@ -516,7 +516,7 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { ERC20LaunchOptions memory _tokenOpts = tokenOpts; // Create the ERC20 token. - ERC20_CREATOR.createToken( + ERC20_CREATOR.createToken{ value: totalContributions_ }( address(_party), _tokenOpts.name, _tokenOpts.symbol, From bb04b2e611f18dd3c0a4665546041a5bf3e25f9d Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 20 Mar 2024 18:53:47 -0400 Subject: [PATCH 07/15] Fix and add a test (#382) --- .gitmodules | 3 + contracts/crowdfund/CrowdfundFactory.sol | 60 ++++++++++ lib/erc20-creator | 1 + remappings.txt | 3 +- .../ERC20LaunchCrowdfundForked.t.sol | 109 ++++++++++++++++++ 5 files changed, 175 insertions(+), 1 deletion(-) create mode 160000 lib/erc20-creator create mode 100644 test/crowdfund/ERC20LaunchCrowdfundForked.t.sol diff --git a/.gitmodules b/.gitmodules index 1b2a2aa2..2130c990 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,3 +16,6 @@ [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/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/remappings.txt b/remappings.txt index 60096651..f11a9d76 100644 --- a/remappings.txt +++ b/remappings.txt @@ -4,4 +4,5 @@ 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/ \ No newline at end of file +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..68e9fe0a --- /dev/null +++ b/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8; + +import { SetupPartyHelper } 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(); + + 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.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, ""); + 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); + } + + receive() external payable {} +} From 84023886b40d29368f0026866b92643d9bcaff9d Mon Sep 17 00:00:00 2001 From: Brian Le Date: Thu, 21 Mar 2024 10:06:13 -0400 Subject: [PATCH 08/15] remove unused `GovernableERC20` --- contracts/tokens/GovernableERC20.sol | 35 ---------------------------- 1 file changed, 35 deletions(-) delete mode 100644 contracts/tokens/GovernableERC20.sol diff --git a/contracts/tokens/GovernableERC20.sol b/contracts/tokens/GovernableERC20.sol deleted file mode 100644 index 7f65fad9..00000000 --- a/contracts/tokens/GovernableERC20.sol +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8; - -import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; -import "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Permit.sol"; -import "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Votes.sol"; - -contract GovernableERC20 is ERC20, ERC20Permit, ERC20Votes { - constructor( - string memory _name, - string memory _symbol, - uint256 _totalSupply, - address _receiver - ) ERC20(_name, _symbol) ERC20Permit(_name) { - _mint(_receiver, _totalSupply); - } - - // Default to self-delegation if no delegate is set. This enables snapshots - // to work as expected, otherwise when transferring votes to undelegated addresses - // the votes would not be moved (see `Votes._moveDelegateVotes`). - function delegates(address account) public view override returns (address) { - address delegate = super.delegates(account); - return delegate == address(0) ? account : delegate; - } - - // The following functions are overrides required by Solidity. - - function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) { - super._update(from, to, value); - } - - function nonces(address owner) public view override(ERC20Permit, Nonces) returns (uint256) { - return super.nonces(owner); - } -} From 12b6759ab529666afe071442652c0570b95158c6 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 21 Mar 2024 12:25:27 -0400 Subject: [PATCH 09/15] refactor `ERC20LaunchCrowdfund` to reduce code size (#383) --- contracts/crowdfund/ERC20LaunchCrowdfund.sol | 443 +------------------ contracts/crowdfund/InitialETHCrowdfund.sol | 2 +- 2 files changed, 11 insertions(+), 434 deletions(-) diff --git a/contracts/crowdfund/ERC20LaunchCrowdfund.sol b/contracts/crowdfund/ERC20LaunchCrowdfund.sol index 2905338e..f22b2133 100644 --- a/contracts/crowdfund/ERC20LaunchCrowdfund.sol +++ b/contracts/crowdfund/ERC20LaunchCrowdfund.sol @@ -1,68 +1,17 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.20; -import { ETHCrowdfundBase } from "./ETHCrowdfundBase.sol"; -import { ProposalStorage } from "../proposals/ProposalStorage.sol"; -import { LibAddress } from "../utils/LibAddress.sol"; -import { LibRawResult } from "../utils/LibRawResult.sol"; -import { LibSafeCast } from "../utils/LibSafeCast.sol"; -import { Party, PartyGovernance } from "../party/Party.sol"; -import { Crowdfund } from "../crowdfund/Crowdfund.sol"; +import { InitialETHCrowdfund } from "./InitialETHCrowdfund.sol"; +import { Party } from "../party/Party.sol"; import { MetadataProvider } from "../renderers/MetadataProvider.sol"; -import { IGateKeeper } from "../gatekeepers/IGateKeeper.sol"; import { IGlobals } from "../globals/IGlobals.sol"; -import { IERC721 } from "../tokens/IERC721.sol"; import { IERC20Creator, TokenConfiguration } from "../utils/IERC20Creator.sol"; /// @notice A crowdfund for raising the initial funds for new parties. /// Unlike other crowdfunds that are started for the purpose of /// acquiring NFT(s), this crowdfund simply bootstraps a party with /// funds and lets its members coordinate on what to do with it after. -contract ERC20LaunchCrowdfund is ETHCrowdfundBase { - using LibRawResult for bytes; - using LibSafeCast for uint256; - using LibAddress for address payable; - - // Options to be passed into `initialize()` when the crowdfund is created. - struct InitialETHCrowdfundOptions { - address payable initialContributor; - address initialDelegate; - uint96 minContribution; - uint96 maxContribution; - bool disableContributingForExistingCard; - uint96 minTotalContributions; - uint96 maxTotalContributions; - uint160 exchangeRate; - uint16 fundingSplitBps; - address payable fundingSplitRecipient; - uint40 duration; - IGateKeeper gateKeeper; - bytes12 gateKeeperId; - } - - struct ETHPartyOptions { - // Name of the party. - string name; - // Symbol of the party. - string symbol; - // The ID of the customization preset to use for the party card. - uint256 customizationPresetId; - // Options to initialize party governance with. - Crowdfund.FixedGovernanceOpts governanceOpts; - // Options to initialize party proposal engine with. - ProposalStorage.ProposalEngineOpts proposalEngineOpts; - // The tokens that are considered precious by the party.These are - // protected assets and are subject to extra restrictions in proposals - // vs other assets. - IERC721[] preciousTokens; - // The IDs associated with each token in `preciousTokens`. - uint256[] preciousTokenIds; - // The timestamp until which ragequit is enabled. - uint40 rageQuitTimestamp; - // Initial authorities to set on the party - address[] authorities; - } - +contract ERC20LaunchCrowdfund is InitialETHCrowdfund { struct ERC20LaunchOptions { // The name of the ERC20 token launched. string name; @@ -80,49 +29,13 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { uint256 numTokensForLP; } - struct BatchContributeArgs { - // IDs of cards to credit the contributions to. When set to 0, it means - // a new one should be minted. - uint256[] tokenIds; - // The address to which voting power will be delegated for all - // contributions. This will be ignored if recipient has already set a - // delegate. - address initialDelegate; - // The contribution amounts in wei. The length of this array must be - // equal to the length of `tokenIds`. - uint96[] values; - // The data required to be validated by the `gatekeeper`, if set. If no - // `gatekeeper` is set, this can be empty. - bytes[] gateDatas; - } - - struct BatchContributeForArgs { - // IDs of cards to credit the contributions to. When set to 0, it means - // a new one should be minted. - uint256[] tokenIds; - // Addresses of to credit the contributions under. Each contribution - // amount in `values` corresponds to a recipient in this array. - address payable[] recipients; - // The delegate to set for each recipient. This will be ignored if - // recipient has already set a delegate. - address[] initialDelegates; - // The contribution amounts in wei. The length of this array must be - // equal to the length of `recipients`. - uint96[] values; - // The data required to be validated by the `gatekeeper`, if set. If no - // `gatekeeper` is set, this can be empty. - bytes[] gateDatas; - } - - event Refunded(address indexed contributor, uint256 indexed tokenId, uint256 amount); - error InvalidTokenDistribution(); IERC20Creator public immutable ERC20_CREATOR; ERC20LaunchOptions public tokenOpts; - constructor(IGlobals globals, IERC20Creator erc20Creator) ETHCrowdfundBase(globals) { + constructor(IGlobals globals, IERC20Creator erc20Creator) InitialETHCrowdfund(globals) { ERC20_CREATOR = erc20Creator; } @@ -138,7 +51,7 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { ERC20LaunchOptions memory _tokenOpts, MetadataProvider customMetadataProvider, bytes memory customMetadata - ) external payable onlyInitialize { + ) external payable { if ( _tokenOpts.numTokensForDistribution + _tokenOpts.numTokensForRecipient + @@ -150,348 +63,12 @@ contract ERC20LaunchCrowdfund is ETHCrowdfundBase { tokenOpts = _tokenOpts; - // Create party the initial crowdfund will be for. - Party party_ = _createParty(partyOpts, customMetadataProvider, customMetadata); - - // Initialize the crowdfund. - _initialize( - ETHCrowdfundOptions({ - party: party_, - initialContributor: crowdfundOpts.initialContributor, - initialDelegate: crowdfundOpts.initialDelegate, - minContribution: crowdfundOpts.minContribution, - maxContribution: crowdfundOpts.maxContribution, - disableContributingForExistingCard: crowdfundOpts - .disableContributingForExistingCard, - minTotalContributions: crowdfundOpts.minTotalContributions, - maxTotalContributions: crowdfundOpts.maxTotalContributions, - exchangeRate: crowdfundOpts.exchangeRate, - fundingSplitBps: crowdfundOpts.fundingSplitBps, - fundingSplitRecipient: crowdfundOpts.fundingSplitRecipient, - duration: crowdfundOpts.duration, - gateKeeper: crowdfundOpts.gateKeeper, - gateKeeperId: crowdfundOpts.gateKeeperId - }) + InitialETHCrowdfund.initialize( + crowdfundOpts, + partyOpts, + customMetadataProvider, + customMetadata ); - - // If the creator passed in some ETH during initialization, credit them - // for the initial contribution. - uint96 initialContribution = msg.value.safeCastUint256ToUint96(); - if (initialContribution > 0) { - _contribute( - crowdfundOpts.initialContributor, - crowdfundOpts.initialDelegate, - initialContribution, - 0, - "" - ); - } - - // Set up gatekeeper after initial contribution (initial always gets in). - gateKeeper = crowdfundOpts.gateKeeper; - gateKeeperId = crowdfundOpts.gateKeeperId; - } - - /// @notice Contribute ETH to this crowdfund. - /// @param initialDelegate The address to which voting power will be delegated to - /// during the governance phase. This will be ignored - /// if recipient has already set a delegate. - /// @param gateData Data to pass to the gatekeeper to prove eligibility. - /// @return votingPower The voting power the contributor receives for their - /// contribution. - function contribute( - address initialDelegate, - bytes memory gateData - ) public payable onlyDelegateCall returns (uint96 votingPower) { - return - _contribute( - payable(msg.sender), - initialDelegate, - msg.value.safeCastUint256ToUint96(), - 0, // Mint a new party card for the contributor. - gateData - ); - } - - /// @notice Contribute ETH to this crowdfund. - /// @param tokenId The ID of the card the contribution is being made towards. - /// @param initialDelegate The address to which voting power will be delegated to - /// during the governance phase. This will be ignored - /// if recipient has already set a delegate. - /// @param gateData Data to pass to the gatekeeper to prove eligibility. - /// @return votingPower The voting power the contributor receives for their - /// contribution. - function contribute( - uint256 tokenId, - address initialDelegate, - bytes memory gateData - ) public payable onlyDelegateCall returns (uint96 votingPower) { - return - _contribute( - payable(msg.sender), - initialDelegate, - msg.value.safeCastUint256ToUint96(), - tokenId, - gateData - ); - } - - /// @notice `contribute()` in batch form. - /// May not revert if any individual contribution fails. - /// @param args The arguments to pass to each `contribute()` call. - /// @return votingPowers The voting power received for each contribution. - function batchContribute( - BatchContributeArgs calldata args - ) external payable onlyDelegateCall returns (uint96[] memory votingPowers) { - uint256 numContributions = args.tokenIds.length; - - if (numContributions != args.values.length || numContributions != args.gateDatas.length) { - revert ArityMismatch(); - } - - votingPowers = new uint96[](numContributions); - uint256 valuesSum; - - for (uint256 i; i < numContributions; ++i) { - votingPowers[i] = _contribute( - payable(msg.sender), - args.initialDelegate, - args.values[i], - args.tokenIds[i], - args.gateDatas[i] - ); - valuesSum += args.values[i]; - } - if (msg.value != valuesSum) { - revert InvalidMessageValue(); - } - } - - /// @notice Contribute to this crowdfund on behalf of another address. - /// @param tokenId The ID of the token to credit the contribution to, or - /// zero to mint a new party card for the recipient - /// @param recipient The address to record the contribution under - /// @param initialDelegate The address to which voting power will be delegated to - /// during the governance phase. This will be ignored - /// if recipient has already set a delegate. - /// @param gateData Data to pass to the gatekeeper to prove eligibility - /// @return votingPower The voting power received for the contribution - function contributeFor( - uint256 tokenId, - address payable recipient, - address initialDelegate, - bytes memory gateData - ) external payable onlyDelegateCall returns (uint96 votingPower) { - return - _contribute( - recipient, - initialDelegate, - msg.value.safeCastUint256ToUint96(), - tokenId, - gateData - ); - } - - /// @notice `contributeFor()` in batch form. - /// May not revert if any individual contribution fails. - /// @param args The arguments for the batched `contributeFor()` calls. - /// @return votingPowers The voting power received for each contribution. - function batchContributeFor( - BatchContributeForArgs calldata args - ) external payable onlyDelegateCall returns (uint96[] memory votingPowers) { - uint256 numContributions = args.tokenIds.length; - - if ( - numContributions != args.values.length || - numContributions != args.gateDatas.length || - numContributions != args.recipients.length - ) { - revert ArityMismatch(); - } - - votingPowers = new uint96[](numContributions); - uint256 valuesSum; - - for (uint256 i; i < numContributions; ++i) { - votingPowers[i] = _contribute( - args.recipients[i], - args.initialDelegates[i], - args.values[i], - args.tokenIds[i], - args.gateDatas[i] - ); - valuesSum += args.values[i]; - } - if (msg.value != valuesSum) { - revert InvalidMessageValue(); - } - } - - function _contribute( - address payable contributor, - address delegate, - uint96 amount, - uint256 tokenId, - bytes memory gateData - ) private returns (uint96 votingPower) { - // Require a non-null delegate. - if (delegate == address(0)) { - revert InvalidDelegateError(); - } - - // Must not be blocked by gatekeeper. - IGateKeeper _gateKeeper = gateKeeper; - if (_gateKeeper != IGateKeeper(address(0))) { - // Checking msg.sender here instead of contributor is intentional to - // allow someone who's allowed by a gatekeeper to invite others - // into the Party. For example, to allow another contract, and - // only that contract, to process contributions on behalf of - // contributors. - if (!_gateKeeper.isAllowed(msg.sender, gateKeeperId, gateData)) { - revert NotAllowedByGateKeeperError(msg.sender, _gateKeeper, gateKeeperId, gateData); - } - } - - votingPower = _processContribution(contributor, delegate, amount); - - // OK to contribute with zero just to update delegate. - if (amount == 0) return 0; - - if (tokenId == 0) { - // Mint contributor a new party card. - party.mint(contributor, votingPower, delegate); - } else if (disableContributingForExistingCard) { - revert ContributingForExistingCardDisabledError(); - } else if (party.ownerOf(tokenId) == contributor) { - // Increase voting power of contributor's existing party card. - party.increaseVotingPower(tokenId, votingPower); - } else { - revert NotOwnerError(tokenId); - } - } - - /// @notice Refund the owner of a party card and burn it. Only available if - /// the crowdfund lost. Can be called to refund for self or on - /// another's behalf. - /// @param tokenId The ID of the party card to refund the owner of then burn. - /// @return amount The amount of ETH refunded to the contributor. - function refund(uint256 tokenId) external returns (uint96 amount) { - // Check crowdfund lifecycle. - { - CrowdfundLifecycle lc = getCrowdfundLifecycle(); - if (lc != CrowdfundLifecycle.Lost) { - revert WrongLifecycleError(lc); - } - } - - // Get amount to refund. - uint96 votingPower = party.votingPowerByTokenId(tokenId).safeCastUint256ToUint96(); - amount = convertVotingPowerToContribution(votingPower); - - if (amount > 0) { - // Get contributor to refund. - address payable contributor = payable(party.ownerOf(tokenId)); - - // Burn contributor's party card. - party.burn(tokenId); - - // Refund contributor. - contributor.transferEth(amount); - - emit Refunded(contributor, tokenId, amount); - } - } - - /// @notice `refund()` in batch form. - /// May not revert if any individual refund fails. - /// @param tokenIds The IDs of the party cards to burn and refund the owners of. - /// @param revertOnFailure If true, revert if any refund fails. - /// @return amounts The amounts of ETH refunded for each refund. - function batchRefund( - uint256[] calldata tokenIds, - bool revertOnFailure - ) external returns (uint96[] memory amounts) { - uint256 numRefunds = tokenIds.length; - amounts = new uint96[](numRefunds); - - for (uint256 i; i < numRefunds; ++i) { - (bool s, bytes memory r) = address(this).call( - abi.encodeCall(this.refund, (tokenIds[i])) - ); - - if (!s) { - if (revertOnFailure) { - r.rawRevert(); - } - } else { - amounts[i] = abi.decode(r, (uint96)); - } - } - } - - function _createParty( - ETHPartyOptions memory opts, - MetadataProvider customMetadataProvider, - bytes memory customMetadata - ) private returns (Party) { - uint256 authoritiesLength = opts.authorities.length + 1; - address[] memory authorities = new address[](authoritiesLength); - for (uint i = 0; i < authoritiesLength - 1; ++i) { - authorities[i] = opts.authorities[i]; - } - authorities[authoritiesLength - 1] = address(this); - - if (address(customMetadataProvider) == address(0)) { - return - opts.governanceOpts.partyFactory.createParty( - opts.governanceOpts.partyImpl, - authorities, - Party.PartyOptions({ - name: opts.name, - symbol: opts.symbol, - customizationPresetId: opts.customizationPresetId, - governance: PartyGovernance.GovernanceOpts({ - hosts: opts.governanceOpts.hosts, - voteDuration: opts.governanceOpts.voteDuration, - executionDelay: opts.governanceOpts.executionDelay, - passThresholdBps: opts.governanceOpts.passThresholdBps, - totalVotingPower: 0, - feeBps: opts.governanceOpts.feeBps, - feeRecipient: opts.governanceOpts.feeRecipient - }), - proposalEngine: opts.proposalEngineOpts - }), - opts.preciousTokens, - opts.preciousTokenIds, - opts.rageQuitTimestamp - ); - } else { - return - opts.governanceOpts.partyFactory.createPartyWithMetadata( - opts.governanceOpts.partyImpl, - authorities, - Party.PartyOptions({ - name: opts.name, - symbol: opts.symbol, - customizationPresetId: opts.customizationPresetId, - governance: PartyGovernance.GovernanceOpts({ - hosts: opts.governanceOpts.hosts, - voteDuration: opts.governanceOpts.voteDuration, - executionDelay: opts.governanceOpts.executionDelay, - passThresholdBps: opts.governanceOpts.passThresholdBps, - totalVotingPower: 0, - feeBps: opts.governanceOpts.feeBps, - feeRecipient: opts.governanceOpts.feeRecipient - }), - proposalEngine: opts.proposalEngineOpts - }), - opts.preciousTokens, - opts.preciousTokenIds, - opts.rageQuitTimestamp, - customMetadataProvider, - customMetadata - ); - } } function _finalize(uint96 totalContributions_) internal override { 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); From e28cce55bb32dd0db1ba93790dfbd10ffb25912e Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 25 Mar 2024 10:51:57 -0400 Subject: [PATCH 10/15] Enforce token `totalSupply` fits in `uint112` --- contracts/crowdfund/ERC20LaunchCrowdfund.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contracts/crowdfund/ERC20LaunchCrowdfund.sol b/contracts/crowdfund/ERC20LaunchCrowdfund.sol index f22b2133..0eb8430b 100644 --- a/contracts/crowdfund/ERC20LaunchCrowdfund.sol +++ b/contracts/crowdfund/ERC20LaunchCrowdfund.sol @@ -56,7 +56,8 @@ contract ERC20LaunchCrowdfund is InitialETHCrowdfund { _tokenOpts.numTokensForDistribution + _tokenOpts.numTokensForRecipient + _tokenOpts.numTokensForLP != - _tokenOpts.totalSupply + _tokenOpts.totalSupply || + _tokenOpts.totalSupply > type(uint112).max ) { revert InvalidTokenDistribution(); } From e4a47d7b7d72af7ca208a0d3dedb9e8175321357 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 25 Mar 2024 14:26:17 -0400 Subject: [PATCH 11/15] Update comment --- contracts/crowdfund/ERC20LaunchCrowdfund.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/crowdfund/ERC20LaunchCrowdfund.sol b/contracts/crowdfund/ERC20LaunchCrowdfund.sol index 0eb8430b..58ddb12d 100644 --- a/contracts/crowdfund/ERC20LaunchCrowdfund.sol +++ b/contracts/crowdfund/ERC20LaunchCrowdfund.sol @@ -7,10 +7,10 @@ import { MetadataProvider } from "../renderers/MetadataProvider.sol"; import { IGlobals } from "../globals/IGlobals.sol"; import { IERC20Creator, TokenConfiguration } from "../utils/IERC20Creator.sol"; -/// @notice A crowdfund for raising the initial funds for new parties. +/// @notice A crowdfund for launching ERC20 tokens. /// Unlike other crowdfunds that are started for the purpose of -/// acquiring NFT(s), this crowdfund simply bootstraps a party with -/// funds and lets its members coordinate on what to do with it after. +/// 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. From 7364870684471700ea4162d8e5edce32b8496864 Mon Sep 17 00:00:00 2001 From: Brian Le Date: Mon, 25 Mar 2024 15:26:27 -0400 Subject: [PATCH 12/15] mitigation L-5 --- contracts/crowdfund/ERC20LaunchCrowdfund.sol | 4 +- .../ERC20LaunchCrowdfundForked.t.sol | 125 +++++++++++++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/contracts/crowdfund/ERC20LaunchCrowdfund.sol b/contracts/crowdfund/ERC20LaunchCrowdfund.sol index 58ddb12d..3ee0d481 100644 --- a/contracts/crowdfund/ERC20LaunchCrowdfund.sol +++ b/contracts/crowdfund/ERC20LaunchCrowdfund.sol @@ -57,7 +57,9 @@ contract ERC20LaunchCrowdfund is InitialETHCrowdfund { _tokenOpts.numTokensForRecipient + _tokenOpts.numTokensForLP != _tokenOpts.totalSupply || - _tokenOpts.totalSupply > type(uint112).max + _tokenOpts.totalSupply > type(uint112).max || + _tokenOpts.numTokensForLP < 1e4 || + crowdfundOpts.fundingSplitBps > 5e3 ) { revert InvalidTokenDistribution(); } diff --git a/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol b/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol index 68e9fe0a..0a9ef605 100644 --- a/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol +++ b/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8; -import { SetupPartyHelper } from "../utils/SetupPartyHelper.sol"; +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"; @@ -105,5 +105,128 @@ contract ERC20LaunchCrowdfundForkedTest is SetupPartyHelper { 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.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.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.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, + "" + ); + } + receive() external payable {} } From 980b259b4cb3c94f10e1d847d27d7096a67e174d Mon Sep 17 00:00:00 2001 From: Brian Le Date: Mon, 25 Mar 2024 16:14:08 -0400 Subject: [PATCH 13/15] fix: L-6 (#386) --- contracts/crowdfund/ERC20LaunchCrowdfund.sol | 57 ++++-- contracts/crowdfund/ETHCrowdfundBase.sol | 3 +- .../ERC20LaunchCrowdfundForked.t.sol | 167 ++++++++++++++++++ 3 files changed, 210 insertions(+), 17 deletions(-) diff --git a/contracts/crowdfund/ERC20LaunchCrowdfund.sol b/contracts/crowdfund/ERC20LaunchCrowdfund.sol index 3ee0d481..75dfeedd 100644 --- a/contracts/crowdfund/ERC20LaunchCrowdfund.sol +++ b/contracts/crowdfund/ERC20LaunchCrowdfund.sol @@ -5,7 +5,7 @@ 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 } from "../utils/IERC20Creator.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 @@ -30,11 +30,14 @@ contract ERC20LaunchCrowdfund is InitialETHCrowdfund { } 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; } @@ -74,30 +77,28 @@ contract ERC20LaunchCrowdfund is InitialETHCrowdfund { ); } - function _finalize(uint96 totalContributions_) internal override { - Party _party = party; + /// @notice Launch the ERC20 token for the Party. + function launchToken() public returns (ERC20 token) { + if (isTokenLaunched) revert TokenAlreadyLaunched(); - // Finalize the crowdfund. - delete expiry; + CrowdfundLifecycle lc = getCrowdfundLifecycle(); + if (lc != CrowdfundLifecycle.Finalized) revert WrongLifecycleError(lc); + + isTokenLaunched = true; + + // Update the party's total voting power + uint96 totalContributions_ = totalContributions; - // 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(); - - ERC20LaunchOptions memory _tokenOpts = tokenOpts; - // Create the ERC20 token. - ERC20_CREATOR.createToken{ value: totalContributions_ }( - address(_party), + ERC20LaunchOptions memory _tokenOpts = tokenOpts; + token = ERC20_CREATOR.createToken{ value: totalContributions_ }( + address(party), _tokenOpts.name, _tokenOpts.symbol, TokenConfiguration({ @@ -109,4 +110,28 @@ contract ERC20LaunchCrowdfund is InitialETHCrowdfund { _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 8e4dc84c..69a3184c 100644 --- a/contracts/crowdfund/ETHCrowdfundBase.sol +++ b/contracts/crowdfund/ETHCrowdfundBase.sol @@ -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. diff --git a/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol b/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol index 0a9ef605..58b6666f 100644 --- a/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol +++ b/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol @@ -18,6 +18,7 @@ contract ERC20LaunchCrowdfundForkedTest is SetupPartyHelper { function setUp() public override onlyForked { super.setUp(); + // Existing addresses on Sepolia creator = new ERC20Creator( ITokenDistributor(address(tokenDistributor)), IUniswapV2Router02(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D), @@ -74,6 +75,7 @@ contract ERC20LaunchCrowdfundForkedTest is SetupPartyHelper { vm.prank(contributor); vm.recordLogs(); launchCrowdfund.contribute{ value: 1 ether }(contributor, ""); + launchCrowdfund.launchToken(); Vm.Log[] memory logs = vm.getRecordedLogs(); uint256 balanceBefore = address(this).balance; @@ -228,5 +230,170 @@ contract ERC20LaunchCrowdfundForkedTest is SetupPartyHelper { ); } + 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.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.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 {} } From fa6f4adc8360a3a02fd002b97985e06075fe51f4 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 25 Mar 2024 17:53:47 -0400 Subject: [PATCH 14/15] Enforce min `minTotalContributions` --- contracts/crowdfund/ERC20LaunchCrowdfund.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contracts/crowdfund/ERC20LaunchCrowdfund.sol b/contracts/crowdfund/ERC20LaunchCrowdfund.sol index 75dfeedd..acdb2b81 100644 --- a/contracts/crowdfund/ERC20LaunchCrowdfund.sol +++ b/contracts/crowdfund/ERC20LaunchCrowdfund.sol @@ -62,7 +62,8 @@ contract ERC20LaunchCrowdfund is InitialETHCrowdfund { _tokenOpts.totalSupply || _tokenOpts.totalSupply > type(uint112).max || _tokenOpts.numTokensForLP < 1e4 || - crowdfundOpts.fundingSplitBps > 5e3 + crowdfundOpts.fundingSplitBps > 5e3 || + crowdfundOpts.minTotalContributions < 1e4 ) { revert InvalidTokenDistribution(); } From 51dc874b655a2fb6e4d8fb02b447393bb857f5f0 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 25 Mar 2024 18:10:30 -0400 Subject: [PATCH 15/15] Fix tests --- test/crowdfund/ERC20LaunchCrowdfundForked.t.sol | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol b/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol index 58b6666f..757f2bae 100644 --- a/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol +++ b/test/crowdfund/ERC20LaunchCrowdfundForked.t.sol @@ -47,6 +47,7 @@ contract ERC20LaunchCrowdfundForkedTest is SetupPartyHelper { 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; @@ -123,6 +124,7 @@ contract ERC20LaunchCrowdfundForkedTest is SetupPartyHelper { 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; @@ -164,6 +166,7 @@ contract ERC20LaunchCrowdfundForkedTest is SetupPartyHelper { 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; @@ -205,6 +208,7 @@ contract ERC20LaunchCrowdfundForkedTest is SetupPartyHelper { 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; @@ -246,6 +250,7 @@ contract ERC20LaunchCrowdfundForkedTest is SetupPartyHelper { 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; @@ -333,6 +338,7 @@ contract ERC20LaunchCrowdfundForkedTest is SetupPartyHelper { 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;