From de1b8f796c67a1485b1798e8aba12909d9c89402 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 30 Nov 2023 10:24:39 -0500 Subject: [PATCH] feat: membership sale (#328) * add `SellPartyCardsAuthority` * update deploy * add a few tests * update events and add helper functions * update relevant abis * fix tests * add more tests * add more tests * update `isSaleActive` logic * move Contributed event * merge `AboveMaximumContributionsError` and `BelowMinimumContributionsError` * remove redundant initial delegate check * fix compile errors * transfer contribution to Party and fix totalContribution * update `ContributionRouter` * fix `isSaleActive` * fix tests and increase coverage * fix test * update contribute event * fix event and tests * tweaks * increase coverage * perf: optimize and refactor `SellPartyCardsAuthority` (#30) * fix tests * update delegate comments * ensure contract is an authority before sale creation * fix deploy * emit `Finalized` if not enough room for another contribution * style: rename `delegate` to `initialDelegate` * fix(`SellPartyCardsAuthority`): mitigations (#329) * Fix merge conflict --------- Co-authored-by: Brian Le --- .../authorities/SellPartyCardsAuthority.sol | 670 ++++++++++++++++++ contracts/crowdfund/ContributionRouter.sol | 9 +- contracts/utils/LibSafeCast.sol | 15 + deploy/Deploy.s.sol | 17 +- .../authorities/SellPartyCardsAuthority.t.sol | 670 ++++++++++++++++++ test/utils/SetupPartyHelper.sol | 2 +- utils/output-abis.ts | 1 + 7 files changed, 1379 insertions(+), 5 deletions(-) create mode 100644 contracts/authorities/SellPartyCardsAuthority.sol create mode 100644 test/authorities/SellPartyCardsAuthority.t.sol diff --git a/contracts/authorities/SellPartyCardsAuthority.sol b/contracts/authorities/SellPartyCardsAuthority.sol new file mode 100644 index 00000000..48d3f212 --- /dev/null +++ b/contracts/authorities/SellPartyCardsAuthority.sol @@ -0,0 +1,670 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import { Party } from "contracts/party/Party.sol"; +import { IGateKeeper } from "contracts/gatekeepers/IGateKeeper.sol"; +import { LibSafeCast } from "contracts/utils/LibSafeCast.sol"; +import { LibAddress } from "contracts/utils/LibAddress.sol"; +import { FixedPointMathLib } from "solmate/utils/FixedPointMathLib.sol"; + +contract SellPartyCardsAuthority { + using FixedPointMathLib for uint96; + using LibSafeCast for uint96; + using LibSafeCast for uint256; + using LibAddress for address payable; + + struct FixedMembershipSaleOpts { + // The price for each membership minted. + uint96 pricePerMembership; + // The voting power for each membership minted. + uint96 votingPowerPerMembership; + // The total number of memberships for sale. + uint96 totalMembershipsForSale; + // The split from each contribution to be received by the + // fundingSplitRecipient, in basis points. + uint16 fundingSplitBps; + // The recipient of the funding split. + address payable fundingSplitRecipient; + // The duration of the sale. + uint40 duration; + // The gatekeeper contract. + IGateKeeper gateKeeper; + // The ID of the gatekeeper. + bytes12 gateKeeperId; + } + + struct FlexibleMembershipSaleOpts { + // The minimum amount that can be contributed. + uint96 minContribution; + // The maximum amount that can be contributed. + uint96 maxContribution; + // The maximum total amount that can be contributed for the sale. + uint96 maxTotalContributions; + // The exchange rate from contribution amount to voting power where + // 100% = 1e18. May be greater than 1e18 (100%). + uint160 exchangeRate; + // The split from each contribution to be received by the + // fundingSplitRecipient, in basis points. + uint16 fundingSplitBps; + // The recipient of the funding split. + address payable fundingSplitRecipient; + // The duration of the sale. + uint40 duration; + // The gatekeeper contract. + IGateKeeper gateKeeper; + // The ID of the gatekeeper. + bytes12 gateKeeperId; + } + + struct SaleState { + // The minimum amount that can be contributed. + uint96 minContribution; + // The maximum amount that can be contributed. + uint96 maxContribution; + // The time at which the sale expires. + uint40 expiry; + // The split from each contribution to be received by the + // fundingSplitRecipient, in basis points. + uint16 fundingSplitBps; + // The recipient of the funding split. + address payable fundingSplitRecipient; + // The total amount that has been contributed. + uint96 totalContributions; + // The maximum total amount that can be contributed for the sale. + uint96 maxTotalContributions; + // The exchange rate from contribution amount to voting power where + // 100% = 1e18. May be greater than 1e18 (100%). + uint160 exchangeRate; + // The gatekeeper contract. + IGateKeeper gateKeeper; + // The ID of the gatekeeper. + bytes12 gateKeeperId; + } + + /// @notice The ID of the last sale for each Party. + mapping(Party party => uint256 lastId) public lastSaleId; + // Details of each sale. + mapping(Party party => mapping(uint256 id => SaleState opts)) private _saleStates; + + event CreatedSale(Party indexed party, uint256 indexed saleId, SaleState state); + event Finalized(Party indexed party, uint256 indexed saleId); + event MintedFromSale( + Party indexed party, + uint256 indexed saledId, + uint256 indexed tokenId, + address sender, + address contributor, + uint96 contribution, + address initialDelegate + ); + + error NotAuthorizedError(); + error MinGreaterThanMaxError(uint96 minContribution, uint96 maxContribution); + error ZeroMaxTotalContributionsError(); + error ZeroExchangeRateError(); + error InvalidBpsError(uint16 fundingSplitBps); + error ZeroVotingPowerError(); + error InvalidMessageValue(); + error OnlyPartyHostError(); + error SaleInactiveError(); + error InvalidInitialDelegateError(); + error NotAllowedByGateKeeperError( + address sender, + IGateKeeper gateKeeper, + bytes12 gateKeeperId, + bytes gateData + ); + error OutOfBoundsContributionsError(uint96 amount, uint96 bound); + error ExceedsRemainingContributionsError(uint96 amount, uint96 remaining); + error ArityMismatch(); + + /// @notice Create a new fixed membership sale. + /// @param opts Options used to initialize the sale. + /// @return saleId The ID of the sale created. + function createFixedMembershipSale( + FixedMembershipSaleOpts calldata opts + ) external returns (uint256 saleId) { + return + _createSale( + SaleState({ + minContribution: opts.pricePerMembership, + maxContribution: opts.pricePerMembership, + totalContributions: 0, + maxTotalContributions: opts.pricePerMembership * opts.totalMembershipsForSale, + exchangeRate: ( + opts.votingPowerPerMembership.mulDivDown(1e18, opts.pricePerMembership) + ).safeCastUint256ToUint160(), + fundingSplitBps: opts.fundingSplitBps, + fundingSplitRecipient: opts.fundingSplitRecipient, + expiry: uint40(block.timestamp + opts.duration), + gateKeeper: opts.gateKeeper, + gateKeeperId: opts.gateKeeperId + }) + ); + } + + /// @notice Create a new flexible membership sale. + /// @param opts Options used to initialize the sale. + /// @return saleId The ID of the sale created. + function createFlexibleMembershipSale( + FlexibleMembershipSaleOpts calldata opts + ) external returns (uint256 saleId) { + return + _createSale( + SaleState({ + minContribution: opts.minContribution, + maxContribution: opts.maxContribution, + totalContributions: 0, + maxTotalContributions: opts.maxTotalContributions, + exchangeRate: opts.exchangeRate, + fundingSplitBps: opts.fundingSplitBps, + fundingSplitRecipient: opts.fundingSplitRecipient, + expiry: uint40(block.timestamp + opts.duration), + gateKeeper: opts.gateKeeper, + gateKeeperId: opts.gateKeeperId + }) + ); + } + + function _createSale(SaleState memory state) private returns (uint256 saleId) { + if (state.minContribution > state.maxContribution) + revert MinGreaterThanMaxError(state.minContribution, state.maxContribution); + if (state.maxTotalContributions == 0) revert ZeroMaxTotalContributionsError(); + if (state.exchangeRate == 0) revert ZeroExchangeRateError(); + if (state.fundingSplitBps > 1e4) revert InvalidBpsError(state.fundingSplitBps); + + Party party = Party(payable(msg.sender)); + + // Ensure that this contract is an authority in the Party. + if (!party.isAuthority(address(this))) revert NotAuthorizedError(); + + // Create sale. + saleId = ++lastSaleId[party]; + _saleStates[party][saleId] = state; + + emit CreatedSale(party, saleId, state); + } + + /// @notice Contribute to a sale and receive a minted NFT from the Party. + /// @param party The Party to contribute to. + /// @param saleId The ID of the sale to contribute to. + /// @param initialDelegate The delegate to use for the contribution. This will be + /// ignored if caller has already set a delegate. + /// @param gateData Data to pass to the gatekeeper. + /// @return votingPower The voting power received from the contribution. + function contribute( + Party party, + uint256 saleId, + address initialDelegate, + bytes calldata gateData + ) external payable returns (uint96 votingPower) { + uint96 contribution = msg.value.safeCastUint256ToUint96(); + + (votingPower, contribution) = _contribute(party, saleId, contribution, gateData); + + _mint(party, saleId, msg.sender, contribution, votingPower, initialDelegate); + } + + /// @notice Contribute to a sale and receive a minted NFT from the Party. + /// @param party The Party to contribute to. + /// @param saleId The ID of the sale to contribute to. + /// @param recipient The recipient of the minted NFT. + /// @param initialDelegate The delegate to use for the contribution. This will be + /// ignored if recipient has already set a delegate. + /// @param gateData Data to pass to the gatekeeper. + /// @return votingPower The voting power received from the contribution. + function contributeFor( + Party party, + uint256 saleId, + address recipient, + address initialDelegate, + bytes calldata gateData + ) external payable returns (uint96 votingPower) { + uint96 contribution = msg.value.safeCastUint256ToUint96(); + + (votingPower, contribution) = _contribute(party, saleId, contribution, gateData); + + _mint(party, saleId, recipient, contribution, votingPower, initialDelegate); + } + + /// @notice Contribute to a sale and receive a minted NFT from the Party. + /// @param party The Party to contribute to. + /// @param saleId The ID of the sale to contribute to. + /// @param initialDelegate The delegate to use for all contributions. This will be + /// ignored if caller has already set a delegate. + /// @param contributions The amounts of each contribution. + /// @param gateData Data to pass to the gatekeeper. + /// @return votingPowers The voting powers received from each contribution. + function batchContribute( + Party party, + uint256 saleId, + address initialDelegate, + uint96[] memory contributions, + bytes calldata gateData + ) external payable returns (uint96[] memory votingPowers) { + (votingPowers, contributions) = _batchContribute(party, saleId, contributions, gateData); + + for (uint256 i; i < contributions.length; ++i) { + _mint(party, saleId, msg.sender, contributions[i], votingPowers[i], initialDelegate); + } + } + + /// @notice Contribute to a sale and receive a minted NFT from the Party. + /// @param party The Party to contribute to. + /// @param saleId The ID of the sale to contribute to. + /// @param recipients The recipients of the minted NFTs. + /// @param initialDelegates The delegates to use for each contribution. This will be + /// ignored if recipient has already set a delegate. + /// @param contributions The amounts of each contribution. + /// @param gateData Data to pass to the gatekeeper. + /// @return votingPowers The voting powers received from each contribution. + function batchContributeFor( + Party party, + uint256 saleId, + address[] calldata recipients, + address[] calldata initialDelegates, + uint96[] memory contributions, + bytes calldata gateData + ) external payable returns (uint96[] memory votingPowers) { + if ( + recipients.length != initialDelegates.length || + recipients.length != contributions.length + ) revert ArityMismatch(); + + (votingPowers, contributions) = _batchContribute(party, saleId, contributions, gateData); + + for (uint256 i; i < recipients.length; ++i) { + _mint( + party, + saleId, + recipients[i], + contributions[i], + votingPowers[i], + initialDelegates[i] + ); + } + } + + /// @notice Finalize a sale early before the expiry as a host. + /// @param party The Party to finalize the sale for. + /// @param saleId The ID of the sale to finalize. + function finalize(Party party, uint256 saleId) external { + SaleState memory state = _saleStates[party][saleId]; + + // Check that the sale is active. + if ( + _isSaleActive( + state.expiry, + state.totalContributions, + state.minContribution, + state.maxTotalContributions + ) + ) { + // Allow host to finalize sale early. + if (!party.isHost(msg.sender)) revert OnlyPartyHostError(); + + _saleStates[party][saleId].expiry = uint40(block.timestamp); + + emit Finalized(party, saleId); + } else { + // Already finalized. + revert SaleInactiveError(); + } + } + + /// @notice Get the details of a fixed membership sale. + /// @param party The Party that created the sale. + /// @param saleId The ID of the sale. + /// @return pricePerMembership The price for each membership minted. + /// @return votingPowerPerMembership The voting power for each membership + /// minted. + /// @return totalContributions The total amount that has been contributed. + /// @return totalMembershipsForSale The total number of memberships for + /// sale. + /// @return fundingSplitBps The split from each contribution to be received + /// by the fundingSplitRecipient, in basis points. + /// @return fundingSplitRecipient The recipient of the funding split. + /// @return expiry The time at which the sale expires. + /// @return gateKeeper The gatekeeper contract. + /// @return gateKeeperId The ID of the gatekeeper. + function getFixedMembershipSaleInfo( + Party party, + uint256 saleId + ) + external + view + returns ( + uint96 pricePerMembership, + uint96 votingPowerPerMembership, + uint96 totalContributions, + uint96 totalMembershipsForSale, + uint16 fundingSplitBps, + address payable fundingSplitRecipient, + uint40 expiry, + IGateKeeper gateKeeper, + bytes12 gateKeeperId + ) + { + SaleState memory opts = _saleStates[party][saleId]; + pricePerMembership = opts.minContribution; + votingPowerPerMembership = _convertContributionToVotingPower( + pricePerMembership, + opts.exchangeRate + ); + totalContributions = opts.totalContributions; + totalMembershipsForSale = opts.maxTotalContributions / opts.minContribution; + fundingSplitBps = opts.fundingSplitBps; + fundingSplitRecipient = opts.fundingSplitRecipient; + expiry = opts.expiry; + gateKeeper = opts.gateKeeper; + gateKeeperId = opts.gateKeeperId; + } + + /// @notice Get the details of a flexible membership sale. + /// @param party The Party that created the sale. + /// @param saleId The ID of the sale. + /// @return minContribution The minimum amount that can be contributed. + /// @return maxContribution The maximum amount that can be contributed. + /// @return totalContributions The total amount that has been contributed. + /// @return maxTotalContributions The maximum total amount that can be + /// contributed for the sale. + /// @return exchangeRate The exchange rate from contribution amount to + /// voting power. + /// @return fundingSplitBps The split from each contribution to be received + /// by the fundingSplitRecipient, in basis points. + /// @return fundingSplitRecipient The recipient of the funding split. + /// @return expiry The time at which the sale expires. + /// @return gateKeeper The gatekeeper contract. + /// @return gateKeeperId The ID of the gatekeeper. + function getFlexibleMembershipSaleInfo( + Party party, + uint256 saleId + ) + external + view + returns ( + uint96 minContribution, + uint96 maxContribution, + uint96 totalContributions, + uint96 maxTotalContributions, + uint160 exchangeRate, + uint16 fundingSplitBps, + address payable fundingSplitRecipient, + uint40 expiry, + IGateKeeper gateKeeper, + bytes12 gateKeeperId + ) + { + SaleState memory opts = _saleStates[party][saleId]; + minContribution = opts.minContribution; + maxContribution = opts.maxContribution; + totalContributions = opts.totalContributions; + maxTotalContributions = opts.maxTotalContributions; + exchangeRate = opts.exchangeRate; + fundingSplitBps = opts.fundingSplitBps; + fundingSplitRecipient = opts.fundingSplitRecipient; + expiry = opts.expiry; + gateKeeper = opts.gateKeeper; + gateKeeperId = opts.gateKeeperId; + } + + /// @notice Check if a sale is active. + /// @param party The Party that created the sale. + /// @param saleId The ID of the sale. + /// @return status Whether the sale is active or not. + function isSaleActive(Party party, uint256 saleId) external view returns (bool) { + SaleState memory opts = _saleStates[party][saleId]; + return + _isSaleActive( + opts.expiry, + opts.totalContributions, + opts.minContribution, + opts.maxTotalContributions + ); + } + + /// @notice Convert a contribution amount to voting power. + /// @param party The Party that created the sale. + /// @param saleId The ID of the sale. + /// @param contribution The contribution amount. + /// @return votingPower The voting power amount that would be received from + /// the contribution. + function convertContributionToVotingPower( + Party party, + uint256 saleId, + uint96 contribution + ) external view returns (uint96) { + uint160 exchangeRate = _saleStates[party][saleId].exchangeRate; + return _convertContributionToVotingPower(contribution, exchangeRate); + } + + /// @notice Convert a voting power amount to a contribution amount. + /// @param party The Party that created the sale. + /// @param saleId The ID of the sale. + /// @param votingPower The voting power amount. + /// @return contribution The contribution amount that would be required to + /// receive the voting power. + function convertVotingPowerToContribution( + Party party, + uint256 saleId, + uint96 votingPower + ) external view returns (uint96) { + uint160 exchangeRate = _saleStates[party][saleId].exchangeRate; + return _convertVotingPowerToContribution(votingPower, exchangeRate); + } + + function _contribute( + Party party, + uint256 saleId, + uint96 contribution, + bytes calldata gateData + ) private returns (uint96 votingPower, uint96 /* contribution */) { + SaleState memory state = _validateContribution(party, saleId, gateData); + + uint96 contributionToTransfer; + (votingPower, contribution, contributionToTransfer, ) = _processContribution( + party, + saleId, + state, + contribution + ); + + // Transfer amount due to the Party. Revert if the transfer fails. + payable(address(party)).transferEth(contributionToTransfer); + + // Mint contributor a new party card. + party.increaseTotalVotingPower(votingPower); + + return (votingPower, contribution); + } + + function _batchContribute( + Party party, + uint256 saleId, + uint96[] memory contributions, + bytes calldata gateData + ) private returns (uint96[] memory votingPowers, uint96[] memory /* contributions */) { + SaleState memory state = _validateContribution(party, saleId, gateData); + + uint96 totalValue; + uint96 totalVotingPower; + uint96 totalContributionsToTransfer; + votingPowers = new uint96[](contributions.length); + for (uint256 i; i < contributions.length; ++i) { + uint96 contributionToTransfer; + ( + votingPowers[i], + contributions[i], + contributionToTransfer, + state.totalContributions + ) = _processContribution(party, saleId, state, contributions[i]); + + totalValue += contributions[i]; + totalVotingPower += votingPowers[i]; + totalContributionsToTransfer += contributionToTransfer; + } + + if (msg.value != totalValue) revert InvalidMessageValue(); + + // Transfer amount due to the Party. Revert if the transfer fails. + payable(address(party)).transferEth(totalContributionsToTransfer); + + party.increaseTotalVotingPower(totalVotingPower); + + return (votingPowers, contributions); + } + + /// @dev `totalContributions` is updated and returned for use in + /// `batchContribute` and `batchContributeFor`. + function _processContribution( + Party party, + uint256 saleId, + SaleState memory state, + uint96 contribution + ) + private + returns ( + uint96 votingPower, + uint96 contributionUsed, + uint96 contributionToTransfer, + uint96 totalContributions + ) + { + totalContributions = state.totalContributions; + uint96 maxTotalContributions = state.maxTotalContributions; + + // Check sale is active. + if ( + !_isSaleActive( + state.expiry, + totalContributions, + state.minContribution, + maxTotalContributions + ) + ) { + revert SaleInactiveError(); + } + + // Check that the contribution amount is at or below the maximum. + uint96 maxContribution = state.maxContribution; + if (contribution > maxContribution) { + revert OutOfBoundsContributionsError(contribution, maxContribution); + } + + uint96 minContribution = state.minContribution; + uint96 newTotalContributions = totalContributions + contribution; + if (newTotalContributions > maxTotalContributions) { + revert ExceedsRemainingContributionsError( + contribution, + maxTotalContributions - totalContributions + ); + } else { + _saleStates[party][saleId] + .totalContributions = totalContributions = newTotalContributions; + + // Check if not enough room for another contribution. If so, sale is + // finalized. + if (minContribution > maxTotalContributions - newTotalContributions) { + emit Finalized(party, saleId); + } + } + + // Check that the contribution amount is at or above the minimum. This + // is done after `amount` is potentially reduced if refunding excess + // contribution. + if (contribution < minContribution) { + revert OutOfBoundsContributionsError(contribution, minContribution); + } + + // Return contribution amount used after refund and including amount + // used for funding split. Will be emitted in `MintedFromSale` event. + contributionUsed = contribution; + + // Subtract split from contribution amount if applicable. + address payable fundingSplitRecipient = state.fundingSplitRecipient; + uint16 fundingSplitBps = state.fundingSplitBps; + if (fundingSplitRecipient != address(0) && fundingSplitBps > 0) { + // Calculate funding split in a way that avoids rounding errors for + // very small contributions <1e4 wei. + uint96 fundingSplit = (contribution * fundingSplitBps) / 1e4; + + contribution -= fundingSplit; + + // Transfer contribution to funding split recipient if applicable. Do not + // revert if the transfer fails. + fundingSplitRecipient.call{ value: fundingSplit }(""); + } + + // Return contribution amount to transfer to the Party. + contributionToTransfer = contribution; + + // Calculate voting power. + votingPower = _convertContributionToVotingPower(contribution, state.exchangeRate); + + if (votingPower == 0) revert ZeroVotingPowerError(); + } + + function _validateContribution( + Party party, + uint256 saleId, + bytes calldata gateData + ) private view returns (SaleState memory state) { + state = _saleStates[party][saleId]; + + // Must not be blocked by gatekeeper. + IGateKeeper gateKeeper = state.gateKeeper; + bytes12 gateKeeperId = state.gateKeeperId; + if (gateKeeper != IGateKeeper(address(0))) { + if (!gateKeeper.isAllowed(msg.sender, gateKeeperId, gateData)) { + revert NotAllowedByGateKeeperError(msg.sender, gateKeeper, gateKeeperId, gateData); + } + } + } + + function _mint( + Party party, + uint256 saleId, + address recipient, + uint96 contribution, + uint96 votingPower, + address initialDelegate + ) private returns (uint256 tokenId) { + tokenId = party.mint(recipient, votingPower, initialDelegate); + emit MintedFromSale( + party, + saleId, + tokenId, + msg.sender, + recipient, + contribution, + initialDelegate + ); + } + + function _convertContributionToVotingPower( + uint96 contribution, + uint160 exchangeRate + ) private pure returns (uint96) { + return contribution.mulDivDown(exchangeRate, 1e18).safeCastUint256ToUint96(); + } + + function _convertVotingPowerToContribution( + uint96 votingPower, + uint160 exchangeRate + ) private pure returns (uint96) { + return votingPower.mulDivUp(1e18, exchangeRate).safeCastUint256ToUint96(); + } + + function _isSaleActive( + uint40 expiry, + uint96 totalContributions, + uint96 minContribution, + uint96 maxTotalContributions + ) private view returns (bool) { + return + // Check this condition first because it is more likely to change + // within the same call. Expiry more likely to remain constant. + maxTotalContributions - totalContributions >= minContribution && + block.timestamp < expiry; + } +} diff --git a/contracts/crowdfund/ContributionRouter.sol b/contracts/crowdfund/ContributionRouter.sol index 151e6a10..30e8dea7 100644 --- a/contracts/crowdfund/ContributionRouter.sol +++ b/contracts/crowdfund/ContributionRouter.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.20; import { LibAddress } from "../utils/LibAddress.sol"; import { LibRawResult } from "../utils/LibRawResult.sol"; import { InitialETHCrowdfund } from "../crowdfund/InitialETHCrowdfund.sol"; +import { SellPartyCardsAuthority } from "../../contracts/authorities/SellPartyCardsAuthority.sol"; contract ContributionRouter { using LibRawResult for bytes; @@ -75,11 +76,13 @@ contract ContributionRouter { assembly { target := shr(96, calldataload(sub(calldatasize(), 20))) } - if (msg.sig == InitialETHCrowdfund.batchContributeFor.selector) { + if ( + msg.sig == InitialETHCrowdfund.batchContributeFor.selector || + msg.sig == SellPartyCardsAuthority.batchContributeFor.selector + ) { uint256 numOfMints; assembly { - // 196 is the offset of the length of `tokenIds` in the - // calldata. + // 196 is the offset of the array length in the calldata. numOfMints := calldataload(196) } feeAmount *= numOfMints; diff --git a/contracts/utils/LibSafeCast.sol b/contracts/utils/LibSafeCast.sol index 49fe7a0a..ac060f85 100644 --- a/contracts/utils/LibSafeCast.sol +++ b/contracts/utils/LibSafeCast.sol @@ -8,6 +8,7 @@ library LibSafeCast { error Uint256ToInt128CastOutOfRangeError(uint256 u256); error Uint256ToUint128CastOutOfRangeError(uint256 u256); error Uint256ToUint40CastOutOfRangeError(uint256 u256); + error Uint96ToUint16CastOutOfRange(uint96 u96); function safeCastUint256ToUint96(uint256 v) internal pure returns (uint96) { if (v > uint256(type(uint96).max)) { @@ -23,6 +24,13 @@ library LibSafeCast { return uint128(v); } + function safeCastUint256ToUint160(uint256 v) internal pure returns (uint160) { + if (v > uint256(type(uint160).max)) { + revert Uint256ToUint128CastOutOfRangeError(v); + } + return uint160(v); + } + function safeCastUint256ToInt192(uint256 v) internal pure returns (int192) { if (v > uint256(uint192(type(int192).max))) { revert Uint256ToInt192CastOutOfRange(v); @@ -30,6 +38,13 @@ library LibSafeCast { return int192(uint192(v)); } + function safeCastUint96ToUint16(uint96 v) internal pure returns (uint16) { + if (v > uint96(type(uint16).max)) { + revert Uint96ToUint16CastOutOfRange(v); + } + return uint16(v); + } + function safeCastUint96ToInt192(uint96 v) internal pure returns (int192) { return int192(uint192(v)); } diff --git a/deploy/Deploy.s.sol b/deploy/Deploy.s.sol index b7bfee29..7346b557 100644 --- a/deploy/Deploy.s.sol +++ b/deploy/Deploy.s.sol @@ -31,6 +31,7 @@ import "../contracts/market-wrapper/NounsMarketWrapper.sol"; import { AtomicManualParty } from "../contracts/crowdfund/AtomicManualParty.sol"; import { ContributionRouter } from "../contracts/crowdfund/ContributionRouter.sol"; import { AddPartyCardsAuthority } from "../contracts/authorities/AddPartyCardsAuthority.sol"; +import { SellPartyCardsAuthority } from "../contracts/authorities/SellPartyCardsAuthority.sol"; import { SSTORE2MetadataProvider } from "../contracts/renderers/SSTORE2MetadataProvider.sol"; import { BasicMetadataProvider } from "../contracts/renderers/BasicMetadataProvider.sol"; import "./LibDeployConstants.sol"; @@ -81,6 +82,7 @@ abstract contract Deploy { AtomicManualParty public atomicManualParty; ContributionRouter public contributionRouter; AddPartyCardsAuthority public addPartyCardsAuthority; + SellPartyCardsAuthority public sellPartyCardsAuthority; function deploy(LibDeployConstants.DeployConstants memory deployConstants) public virtual { _switchDeployer(DeployerRole.Default); @@ -349,6 +351,15 @@ abstract contract Deploy { _trackDeployerGasAfter(); console.log(" Deployed - AddPartyCardsAuthority", address(addPartyCardsAuthority)); + // DEPLOY_SELL_PARTY_CARDS_AUTHORITY + console.log(""); + console.log("### SellPartyCardsAuthority"); + console.log(" Deploying - SellPartyCardsAuthority"); + _trackDeployerGasBefore(); + sellPartyCardsAuthority = new SellPartyCardsAuthority(); + _trackDeployerGasAfter(); + console.log(" Deployed - SellPartyCardsAuthority", address(sellPartyCardsAuthority)); + // DEPLOY_BATCH_BUY_OPERATOR console.log(""); console.log("### CollectionBatchBuyOperator"); @@ -685,7 +696,7 @@ contract DeployScript is Script, Deploy { Deploy.deploy(deployConstants); vm.stopBroadcast(); - AddressMapping[] memory addressMapping = new AddressMapping[](28); + AddressMapping[] memory addressMapping = new AddressMapping[](29); addressMapping[0] = AddressMapping("Globals", address(globals)); addressMapping[1] = AddressMapping("TokenDistributor", address(tokenDistributor)); addressMapping[2] = AddressMapping( @@ -741,6 +752,10 @@ contract DeployScript is Script, Deploy { "AddPartyCardsAuthority", address(addPartyCardsAuthority) ); + addressMapping[28] = AddressMapping( + "SellPartyCardsAuthority", + address(sellPartyCardsAuthority) + ); console.log(""); console.log("### Deployed addresses"); diff --git a/test/authorities/SellPartyCardsAuthority.t.sol b/test/authorities/SellPartyCardsAuthority.t.sol new file mode 100644 index 00000000..e63471f5 --- /dev/null +++ b/test/authorities/SellPartyCardsAuthority.t.sol @@ -0,0 +1,670 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8; + +import { Party, SetupPartyHelper } from "../utils/SetupPartyHelper.sol"; +import { SellPartyCardsAuthority } from "contracts/authorities/SellPartyCardsAuthority.sol"; +import { IGateKeeper } from "contracts/gatekeepers/IGateKeeper.sol"; +import { ContributionRouter } from "../../contracts/crowdfund/ContributionRouter.sol"; +import { TokenGateKeeper, Token } from "contracts/gatekeepers/TokenGateKeeper.sol"; +import { DummyERC20 } from "../DummyERC20.sol"; + +contract SellPartyCardsAuthorityTest is SetupPartyHelper { + event MintedFromSale( + Party indexed party, + uint256 indexed saledId, + uint256 indexed tokenId, + address sender, + address contributor, + uint96 contribution, + address delegate + ); + event Finalized(Party indexed party, uint256 indexed saleId); + + constructor() SetupPartyHelper(false) {} + + SellPartyCardsAuthority internal sellPartyCardsAuthority; + ContributionRouter internal router; + + uint256 lastTokenId; + + function setUp() public override { + super.setUp(); + sellPartyCardsAuthority = new SellPartyCardsAuthority(); + + vm.prank(address(party)); + party.addAuthority(address(sellPartyCardsAuthority)); + router = new ContributionRouter(address(this), 0.0001 ether); + + lastTokenId = party.tokenCount(); + } + + function testSellPartyCards_createNewFixedSaleAndBuyOut() public { + uint96 originalTotalVotingPower = party.getGovernanceValues().totalVotingPower; + uint256 originalPartyBalance = address(party).balance; + uint256 saleId = _createNewFixedSale(); + assertEq(originalTotalVotingPower, party.getGovernanceValues().totalVotingPower); + + for (uint i = 0; i < 3; i++) { + address buyer = _randomAddress(); + vm.deal(buyer, 1 ether); + vm.prank(buyer); + vm.expectEmit(true, true, true, true); + emit MintedFromSale(party, saleId, lastTokenId + i + 1, buyer, buyer, 1 ether, buyer); + sellPartyCardsAuthority.contribute{ value: 1 ether }(party, saleId, buyer, ""); + assertEq( + originalTotalVotingPower + (1 + i) * 0.001 ether, + party.getGovernanceValues().totalVotingPower + ); + assertEq(party.balanceOf(buyer), 1); + vm.roll(block.number + 1); + vm.warp(block.timestamp + 1); + assertEq(party.getVotingPowerAt(buyer, uint40(block.timestamp)), 0.001 ether); + } + + assertEq(address(party).balance, originalPartyBalance + 3 ether); + + // Don't allow further contributions + address buyer = _randomAddress(); + vm.deal(buyer, 1 ether); + vm.prank(buyer); + vm.expectRevert(SellPartyCardsAuthority.SaleInactiveError.selector); + sellPartyCardsAuthority.contribute{ value: 1 ether }(party, saleId, buyer, ""); + } + + function testSellPartyCards_createNewFlexibleSaleAndBuyOut() public { + uint96 originalTotalVotingPower = party.getGovernanceValues().totalVotingPower; + uint256 saleId = _createNewFlexibleSale(); + assertEq(originalTotalVotingPower, party.getGovernanceValues().totalVotingPower); + + for (uint i = 0; i < 3; i++) { + address buyer = _randomAddress(); + uint96 amount = uint96(0.001 ether + i * 0.998 ether); + vm.deal(buyer, 2 ether); + vm.prank(buyer); + vm.expectEmit(true, true, true, true); + emit MintedFromSale(party, saleId, lastTokenId + i + 1, buyer, buyer, amount, buyer); + sellPartyCardsAuthority.contribute{ value: amount }(party, saleId, buyer, ""); + assertEq(party.balanceOf(buyer), 1); + vm.roll(block.number + 1); + vm.warp(block.timestamp + 1); + assertEq(party.getVotingPowerAt(buyer, uint40(block.timestamp)), amount); + } + assertEq( + originalTotalVotingPower + 2.997 ether, + party.getGovernanceValues().totalVotingPower + ); + + // Reduce contribution to available amount + address buyer = _randomAddress(); + vm.deal(buyer, 0.003 ether); + vm.prank(buyer); + vm.expectEmit(true, true, true, true); + emit MintedFromSale(party, saleId, lastTokenId + 4, buyer, buyer, 0.003 ether, buyer); + sellPartyCardsAuthority.contribute{ value: 0.003 ether }(party, saleId, buyer, ""); + + // Don't allow further contributions + buyer = _randomAddress(); + vm.prank(buyer); + vm.deal(buyer, 1 ether); + vm.expectRevert(SellPartyCardsAuthority.SaleInactiveError.selector); + sellPartyCardsAuthority.contribute{ value: 1 ether }(party, saleId, buyer, ""); + } + + function testSellPartyCards_createNewFixedSaleAndBuyOut_batchContribute() public { + uint96 originalTotalVotingPower = party.getGovernanceValues().totalVotingPower; + uint256 saleId = _createNewFixedSale(); + assertEq(originalTotalVotingPower, party.getGovernanceValues().totalVotingPower); + + address buyer = _randomAddress(); + vm.deal(buyer, 3 ether); + + uint96[] memory values = new uint96[](3); + for (uint i = 0; i < 3; i++) { + values[i] = 1 ether; + } + + // First try with incorrect value + vm.expectRevert(SellPartyCardsAuthority.InvalidMessageValue.selector); + vm.prank(buyer); + sellPartyCardsAuthority.batchContribute{ value: 2 ether }(party, saleId, buyer, values, ""); + + vm.prank(buyer); + sellPartyCardsAuthority.batchContribute{ value: 3 ether }(party, saleId, buyer, values, ""); + assertEq( + originalTotalVotingPower + 0.003 ether, + party.getGovernanceValues().totalVotingPower + ); + assertEq(party.balanceOf(buyer), 3); + vm.roll(block.number + 1); + vm.warp(block.timestamp + 1); + assertEq(party.getVotingPowerAt(buyer, uint40(block.timestamp)), 0.003 ether); + } + + function testSellPartyCards_fundingSplit() public { + address payable fundingSplitReceiver = payable(_randomAddress()); + assertEq(fundingSplitReceiver.balance, 0); + uint16 fundingSplitBps = 1000; + + SellPartyCardsAuthority.FixedMembershipSaleOpts memory opts = SellPartyCardsAuthority + .FixedMembershipSaleOpts({ + pricePerMembership: 1 ether, + votingPowerPerMembership: 0.001 ether, + totalMembershipsForSale: 3, + fundingSplitBps: fundingSplitBps, + fundingSplitRecipient: fundingSplitReceiver, + duration: 100, + gateKeeper: IGateKeeper(address(0)), + gateKeeperId: bytes12(0) + }); + + vm.prank(address(party)); + uint256 saleId = sellPartyCardsAuthority.createFixedMembershipSale(opts); + + address buyer = _randomAddress(); + vm.deal(buyer, 1 ether); + vm.prank(buyer); + sellPartyCardsAuthority.contribute{ value: 1 ether }(party, saleId, buyer, ""); + + assertEq(fundingSplitReceiver.balance, (1 ether * uint256(fundingSplitBps)) / 10_000); + } + + function testSellPartyCards_contributeAboveMax() public { + uint256 saleId = _createNewFlexibleSale(); + + address buyer = _randomAddress(); + vm.deal(buyer, 2.5 ether); + vm.prank(buyer); + vm.expectRevert( + abi.encodeWithSelector( + SellPartyCardsAuthority.OutOfBoundsContributionsError.selector, + 2.5 ether, + 2 ether + ) + ); + sellPartyCardsAuthority.contribute{ value: 2.5 ether }(party, saleId, buyer, ""); + } + + function testSellPartyCards_contributeBelowMin() public { + uint256 saleId = _createNewFlexibleSale(); + + address buyer = _randomAddress(); + vm.deal(buyer, 1 ether); + vm.prank(buyer); + vm.expectRevert( + abi.encodeWithSelector( + SellPartyCardsAuthority.OutOfBoundsContributionsError.selector, + 0.0005 ether, + 0.001 ether + ) + ); + sellPartyCardsAuthority.contribute{ value: 0.0005 ether }(party, saleId, buyer, ""); + } + + function testSellPartyCards_contributeAboveRemaining() public { + uint256 saleId = _createNewFlexibleSale(); + + address buyer = _randomAddress(); + vm.deal(buyer, 2 ether); + vm.prank(buyer); + sellPartyCardsAuthority.contribute{ value: 2 ether }(party, saleId, buyer, ""); + + // Contributing above maxTotalContributions (1 ETH remaining) should fail + vm.deal(buyer, 1 ether + 1); + vm.prank(buyer); + vm.expectRevert( + abi.encodeWithSelector( + SellPartyCardsAuthority.ExceedsRemainingContributionsError.selector, + 1 ether + 1, + 1 ether + ) + ); + sellPartyCardsAuthority.contribute{ value: 1 ether + 1 }(party, saleId, buyer, ""); + + // Contributing to maxTotalContributions should succeed and finalize sale + emit Finalized(party, saleId); + sellPartyCardsAuthority.contribute{ value: 1 ether }(party, saleId, buyer, ""); + } + + function testSellPartyCards_createSale_minAboveMax() public { + SellPartyCardsAuthority.FlexibleMembershipSaleOpts memory opts = SellPartyCardsAuthority + .FlexibleMembershipSaleOpts({ + minContribution: 3 ether, + maxContribution: 2 ether, + maxTotalContributions: 3 ether, + exchangeRate: 1e18, + fundingSplitBps: 0, + fundingSplitRecipient: payable(address(0)), + duration: 100, + gateKeeper: IGateKeeper(address(0)), + gateKeeperId: bytes12(0) + }); + + vm.prank(address(party)); + vm.expectRevert( + abi.encodeWithSelector( + SellPartyCardsAuthority.MinGreaterThanMaxError.selector, + 3 ether, + 2 ether + ) + ); + sellPartyCardsAuthority.createFlexibleMembershipSale(opts); + } + + function testSellPartyCards_createSale_totalContributionsZero() public { + SellPartyCardsAuthority.FlexibleMembershipSaleOpts memory opts = SellPartyCardsAuthority + .FlexibleMembershipSaleOpts({ + minContribution: 1 ether, + maxContribution: 2 ether, + maxTotalContributions: 0 ether, + exchangeRate: 1e18, + fundingSplitBps: 0, + fundingSplitRecipient: payable(address(0)), + duration: 100, + gateKeeper: IGateKeeper(address(0)), + gateKeeperId: bytes12(0) + }); + + vm.prank(address(party)); + vm.expectRevert(SellPartyCardsAuthority.ZeroMaxTotalContributionsError.selector); + sellPartyCardsAuthority.createFlexibleMembershipSale(opts); + } + + function testSellPartyCards_createSale_zeroExchangeRate() public { + SellPartyCardsAuthority.FlexibleMembershipSaleOpts memory opts = SellPartyCardsAuthority + .FlexibleMembershipSaleOpts({ + minContribution: 1 ether, + maxContribution: 2 ether, + maxTotalContributions: 5 ether, + exchangeRate: 0, + fundingSplitBps: 0, + fundingSplitRecipient: payable(address(0)), + duration: 100, + gateKeeper: IGateKeeper(address(0)), + gateKeeperId: bytes12(0) + }); + + vm.prank(address(party)); + vm.expectRevert(SellPartyCardsAuthority.ZeroExchangeRateError.selector); + sellPartyCardsAuthority.createFlexibleMembershipSale(opts); + } + + function testSellPartyCards_createSale_invalidFundingSplit() public { + SellPartyCardsAuthority.FlexibleMembershipSaleOpts memory opts = SellPartyCardsAuthority + .FlexibleMembershipSaleOpts({ + minContribution: 1 ether, + maxContribution: 2 ether, + maxTotalContributions: 5 ether, + exchangeRate: 1e18, + fundingSplitBps: 10001, + fundingSplitRecipient: payable(address(this)), + duration: 100, + gateKeeper: IGateKeeper(address(0)), + gateKeeperId: bytes12(0) + }); + + vm.prank(address(party)); + vm.expectRevert( + abi.encodeWithSelector(SellPartyCardsAuthority.InvalidBpsError.selector, 10001) + ); + sellPartyCardsAuthority.createFlexibleMembershipSale(opts); + } + + function testSellPartyCards_contributeFor() public { + uint256 saleId = _createNewFixedSale(); + + address buyer = _randomAddress(); + vm.deal(buyer, 1 ether); + address receiver = _randomAddress(); + + vm.prank(buyer); + vm.expectEmit(true, true, true, true); + emit MintedFromSale(party, saleId, lastTokenId + 1, buyer, receiver, 1 ether, receiver); + sellPartyCardsAuthority.contributeFor{ value: 1 ether }( + party, + saleId, + receiver, + receiver, + "" + ); + + assertEq(party.balanceOf(receiver), 1); + } + + function testSellPartyCards_batchContributeForThroughRouter() public { + uint256 originalPartyBalance = address(party).balance; + uint256 saleId = _createNewFixedSale(); + + address buyer = _randomAddress(); + vm.deal(buyer, 4 ether); + + address receiver = _randomAddress(); + + address[] memory recipients = new address[](3); + address[] memory delegates = new address[](3); + uint96[] memory values = new uint96[](3); + + for (uint i = 0; i < 3; i++) { + recipients[i] = delegates[i] = _randomAddress(); + values[i] = 1 ether; + } + + uint256 feePerMint = router.feePerMint(); + bytes memory data = abi.encodeCall( + SellPartyCardsAuthority.batchContributeFor, + (party, saleId, recipients, delegates, values, "") + ); + + vm.expectRevert(SellPartyCardsAuthority.InvalidMessageValue.selector); + vm.prank(buyer); + address(router).call{ value: 3 ether }(abi.encodePacked(data, sellPartyCardsAuthority)); + + vm.prank(buyer); + (bool success, ) = address(router).call{ value: 3 ether + 3 * feePerMint }( + abi.encodePacked(data, sellPartyCardsAuthority) + ); + + assertTrue(success); + for (uint i = 0; i < 3; i++) { + assertEq(party.balanceOf(recipients[i]), 1); + assertEq(party.getVotingPowerAt(recipients[i], uint40(block.timestamp)), 0.001 ether); + } + + assertEq(address(router).balance, 3 * feePerMint); + assertEq(address(party).balance, originalPartyBalance + 3 ether); + } + + function testSellPartyCards_finalize() public { + uint256 saleId = _createNewFixedSale(); + + address buyer = _randomAddress(); + vm.deal(buyer, 1 ether); + vm.prank(buyer); + sellPartyCardsAuthority.contribute{ value: 1 ether }(party, saleId, buyer, ""); + + // Only from host + vm.prank(john); + vm.expectRevert(SellPartyCardsAuthority.OnlyPartyHostError.selector); + sellPartyCardsAuthority.finalize(party, saleId); + + vm.expectEmit(true, true, true, true); + emit Finalized(party, saleId); + sellPartyCardsAuthority.finalize(party, saleId); + + // Can't contribute anymore + buyer = _randomAddress(); + vm.deal(buyer, 1 ether); + vm.prank(buyer); + vm.expectRevert(SellPartyCardsAuthority.SaleInactiveError.selector); + sellPartyCardsAuthority.contribute{ value: 1 ether }(party, saleId, buyer, ""); + + // Can't finalize again + vm.expectRevert(SellPartyCardsAuthority.SaleInactiveError.selector); + sellPartyCardsAuthority.finalize(party, saleId); + + (, , , , , , uint40 expiry, , ) = sellPartyCardsAuthority.getFixedMembershipSaleInfo( + party, + saleId + ); + assertEq(expiry, uint40(block.timestamp)); + } + + function testSellPartyCards_getFlexibleMembershipSaleInfo() public { + uint256 saleId = _createNewFlexibleSale(); + + vm.warp(block.timestamp + 10); + + ( + uint96 minContribution, + uint96 maxContribution, + uint96 totalContributions, + uint96 maxTotalContributions, + uint160 exchangeRate, + uint16 fundingSplitBps, + address payable fundingSplitRecipient, + uint40 expiry, + IGateKeeper gateKeeper, + bytes12 gateKeeperId + ) = sellPartyCardsAuthority.getFlexibleMembershipSaleInfo(party, saleId); + + assertEq(minContribution, 0.001 ether); + assertEq(maxContribution, 2 ether); + assertEq(totalContributions, 0 ether); + assertEq(maxTotalContributions, 3 ether); + assertEq(exchangeRate, 1e18); + assertEq(fundingSplitBps, 0); + assertEq(fundingSplitRecipient, payable(address(0))); + assertEq(expiry, uint40(block.timestamp + 100 - 10)); + assertEq(address(gateKeeper), address(0)); + assertEq(gateKeeperId, bytes12(0)); + } + + function testSellPartyCards_getFixedMembershipSaleInfo() public { + uint256 saleId = _createNewFixedSale(); + + vm.warp(block.timestamp + 10); + + ( + uint96 pricePerMembership, + uint96 votingPowerPerMembership, + uint96 totalContributions, + uint96 totalMembershipsForSale, + uint16 fundingSplitBps, + address payable fundingSplitRecipient, + uint40 expiry, + IGateKeeper gateKeeper, + bytes12 gateKeeperId + ) = sellPartyCardsAuthority.getFixedMembershipSaleInfo(party, saleId); + + assertEq(pricePerMembership, 1 ether); + assertEq(votingPowerPerMembership, 0.001 ether); + assertEq(totalMembershipsForSale, 3); + assertEq(totalContributions, 0); + assertEq(fundingSplitBps, 0); + assertEq(fundingSplitRecipient, payable(address(0))); + assertEq(expiry, uint40(block.timestamp + 100 - 10)); + assertEq(address(gateKeeper), address(0)); + assertEq(gateKeeperId, bytes12(0)); + } + + function testSellPartyCards_gatekeepers() public { + TokenGateKeeper gatekeeper = new TokenGateKeeper(address(router)); + DummyERC20 token = new DummyERC20(); + + address buyer = _randomAddress(); + vm.deal(buyer, 2 ether); + bytes12 gatekeeperId = gatekeeper.createGate(Token(address(token)), 0.01 ether); + token.deal(buyer, 0.001 ether); + + SellPartyCardsAuthority.FixedMembershipSaleOpts memory opts = SellPartyCardsAuthority + .FixedMembershipSaleOpts({ + pricePerMembership: 1 ether, + votingPowerPerMembership: 0.001 ether, + totalMembershipsForSale: 3, + fundingSplitBps: 0, + fundingSplitRecipient: payable(address(0)), + duration: 100, + gateKeeper: gatekeeper, + gateKeeperId: gatekeeperId + }); + + vm.prank(address(party)); + uint256 saleId = sellPartyCardsAuthority.createFixedMembershipSale(opts); + + vm.prank(buyer); + vm.expectRevert( + abi.encodeWithSelector( + SellPartyCardsAuthority.NotAllowedByGateKeeperError.selector, + buyer, + gatekeeper, + gatekeeperId, + hex"" + ) + ); + sellPartyCardsAuthority.contribute{ value: 1 ether }(party, saleId, buyer, ""); + + token.deal(buyer, 0.01 ether); + vm.prank(buyer); + sellPartyCardsAuthority.contribute{ value: 1 ether }(party, saleId, buyer, ""); + } + + function testSellPartyCards_zeroVotingPower() public { + SellPartyCardsAuthority.FlexibleMembershipSaleOpts memory opts = SellPartyCardsAuthority + .FlexibleMembershipSaleOpts({ + minContribution: 0, + maxContribution: 2 ether, + maxTotalContributions: 3 ether, + exchangeRate: 1e18, + fundingSplitBps: 0, + fundingSplitRecipient: payable(address(0)), + duration: 100, + gateKeeper: IGateKeeper(address(0)), + gateKeeperId: bytes12(0) + }); + + vm.prank(address(party)); + uint256 saleId = sellPartyCardsAuthority.createFlexibleMembershipSale(opts); + + address buyer = _randomAddress(); + vm.deal(buyer, 2 ether); + + vm.prank(buyer); + vm.expectRevert(SellPartyCardsAuthority.ZeroVotingPowerError.selector); + sellPartyCardsAuthority.contribute(party, saleId, buyer, ""); + + vm.prank(buyer); + vm.expectRevert(SellPartyCardsAuthority.ZeroVotingPowerError.selector); + sellPartyCardsAuthority.contributeFor(party, saleId, _randomAddress(), buyer, ""); + + uint96[] memory values = new uint96[](3); + values[0] = 1 ether; + values[1] = 0.2 ether; + + vm.prank(buyer); + vm.expectRevert(SellPartyCardsAuthority.ZeroVotingPowerError.selector); + sellPartyCardsAuthority.batchContribute{ value: 1.2 ether }( + party, + saleId, + buyer, + values, + "" + ); + + address[] memory recipients = new address[](3); + address[] memory delegates = new address[](3); + recipients[0] = delegates[0] = _randomAddress(); + recipients[1] = delegates[1] = _randomAddress(); + recipients[2] = delegates[2] = _randomAddress(); + + vm.prank(buyer); + vm.expectRevert(SellPartyCardsAuthority.ZeroVotingPowerError.selector); + sellPartyCardsAuthority.batchContributeFor{ value: 1.2 ether }( + party, + saleId, + recipients, + delegates, + values, + "" + ); + } + + function testSellPartyCards_precision_upperPrice() public { + SellPartyCardsAuthority.FixedMembershipSaleOpts memory opts = SellPartyCardsAuthority + .FixedMembershipSaleOpts({ + pricePerMembership: 10 ether, + votingPowerPerMembership: 10, // pricePerMembership/votingPowerPerMembership <= 1e18 + totalMembershipsForSale: 30, + fundingSplitBps: 0, + fundingSplitRecipient: payable(address(0)), + duration: 100, + gateKeeper: IGateKeeper(address(0)), + gateKeeperId: bytes12(0) + }); + + vm.prank(address(party)); + uint256 saleId = sellPartyCardsAuthority.createFixedMembershipSale(opts); + + address buyer = _randomAddress(); + vm.deal(buyer, 11 ether); + vm.prank(buyer); + vm.expectEmit(true, true, true, true); + emit MintedFromSale(party, saleId, lastTokenId + 1, buyer, buyer, 10 ether, buyer); + sellPartyCardsAuthority.contribute{ value: 10 ether }(party, saleId, buyer, ""); + + vm.warp(block.timestamp + 10); + assertEq(party.getVotingPowerAt(buyer, uint40(block.timestamp)), 10); + } + + function testSellPartyCards_precision_lowerPrice() public { + SellPartyCardsAuthority.FixedMembershipSaleOpts memory opts = SellPartyCardsAuthority + .FixedMembershipSaleOpts({ + pricePerMembership: 1, + votingPowerPerMembership: 10 ether, // votingPowerPerMembership/pricePerMembership can be much greater than 1e18 + totalMembershipsForSale: 30, + fundingSplitBps: 0, + fundingSplitRecipient: payable(address(0)), + duration: 100, + gateKeeper: IGateKeeper(address(0)), + gateKeeperId: bytes12(0) + }); + + vm.prank(address(party)); + uint256 saleId = sellPartyCardsAuthority.createFixedMembershipSale(opts); + + address buyer = _randomAddress(); + vm.deal(buyer, 1 ether); + vm.prank(buyer); + vm.expectEmit(true, true, true, true); + emit MintedFromSale(party, saleId, lastTokenId + 1, buyer, buyer, 1, buyer); + sellPartyCardsAuthority.contribute{ value: 1 }(party, saleId, buyer, ""); + + vm.warp(block.timestamp + 10); + assertEq(party.getVotingPowerAt(buyer, uint40(block.timestamp)), 10 ether); + } + + function testSellPartyCards_helperFunctions() public { + uint256 saleId = _createNewFixedSale(); + assertEq( + sellPartyCardsAuthority.convertContributionToVotingPower(party, saleId, 1 ether), + 0.001 ether + ); + assertEq( + sellPartyCardsAuthority.convertVotingPowerToContribution(party, saleId, 0.001 ether), + 1 ether + ); + } + + function _createNewFixedSale() internal returns (uint256) { + SellPartyCardsAuthority.FixedMembershipSaleOpts memory opts = SellPartyCardsAuthority + .FixedMembershipSaleOpts({ + pricePerMembership: 1 ether, + votingPowerPerMembership: 0.001 ether, + totalMembershipsForSale: 3, + fundingSplitBps: 0, + fundingSplitRecipient: payable(address(0)), + duration: 100, + gateKeeper: IGateKeeper(address(0)), + gateKeeperId: bytes12(0) + }); + + vm.prank(address(party)); + return sellPartyCardsAuthority.createFixedMembershipSale(opts); + } + + function _createNewFlexibleSale() internal returns (uint256) { + SellPartyCardsAuthority.FlexibleMembershipSaleOpts memory opts = SellPartyCardsAuthority + .FlexibleMembershipSaleOpts({ + minContribution: 0.001 ether, + maxContribution: 2 ether, + maxTotalContributions: 3 ether, + exchangeRate: 1e18, + fundingSplitBps: 0, + fundingSplitRecipient: payable(address(0)), + duration: 100, + gateKeeper: IGateKeeper(address(0)), + gateKeeperId: bytes12(0) + }); + + vm.prank(address(party)); + return sellPartyCardsAuthority.createFlexibleMembershipSale(opts); + } +} diff --git a/test/utils/SetupPartyHelper.sol b/test/utils/SetupPartyHelper.sol index a7824d1e..2b3951fa 100644 --- a/test/utils/SetupPartyHelper.sol +++ b/test/utils/SetupPartyHelper.sol @@ -83,7 +83,7 @@ abstract contract SetupPartyHelper is TestUtils, ERC721Receiver { Party.PartyOptions memory opts; address[] memory hosts = new address[](1); - hosts[0] = address(420); + hosts[0] = address(this); opts.name = "PARTY"; opts.symbol = "PR-T"; opts.governance.hosts = hosts; diff --git a/utils/output-abis.ts b/utils/output-abis.ts index 78824678..8a71fd1e 100644 --- a/utils/output-abis.ts +++ b/utils/output-abis.ts @@ -30,6 +30,7 @@ const RELEVANT_ABIS = [ "AtomicManualParty", "ContributionRouter", "AddPartyCardsAuthority", + "SellPartyCardsAuthority", ]; // AFileName -> a_file_name