From 0822dadb31ca23c9b910b19e8e5ccae6e00ac4c5 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Mon, 30 Jun 2025 09:47:42 +1200 Subject: [PATCH 1/4] ERC1155Sale update --- .../ERC1155/utility/sale/ERC1155Sale.sol | 314 ++----- .../utility/sale/ERC1155SaleFactory.sol | 6 +- .../ERC1155/utility/sale/IERC1155Sale.sol | 119 ++- .../utility/sale/IERC1155SaleFactory.sol | 4 + .../utility/sale/ERC1155SaleBase.t.sol | 285 +++--- .../utility/sale/ERC1155SaleMint.t.sol | 880 +++++++----------- 6 files changed, 657 insertions(+), 951 deletions(-) diff --git a/src/tokens/ERC1155/utility/sale/ERC1155Sale.sol b/src/tokens/ERC1155/utility/sale/ERC1155Sale.sol index 1b616712..625ab747 100644 --- a/src/tokens/ERC1155/utility/sale/ERC1155Sale.sol +++ b/src/tokens/ERC1155/utility/sale/ERC1155Sale.sol @@ -5,7 +5,7 @@ import { MerkleProofSingleUse } from "../../../common/MerkleProofSingleUse.sol"; import { SignalsImplicitModeControlled } from "../../../common/SignalsImplicitModeControlled.sol"; import { AccessControlEnumerable, IERC20, SafeERC20, WithdrawControlled } from "../../../common/WithdrawControlled.sol"; import { IERC1155ItemsFunctions } from "../../presets/items/IERC1155Items.sol"; -import { IERC1155Sale, IERC1155SaleFunctions } from "./IERC1155Sale.sol"; +import { IERC1155Sale } from "./IERC1155Sale.sol"; contract ERC1155Sale is IERC1155Sale, WithdrawControlled, MerkleProofSingleUse, SignalsImplicitModeControlled { @@ -14,11 +14,10 @@ contract ERC1155Sale is IERC1155Sale, WithdrawControlled, MerkleProofSingleUse, bool private _initialized; address private _items; - // ERC20 token address for payment. address(0) indicated payment in ETH. - address private _paymentToken; - - SaleDetails private _globalSaleDetails; - mapping(uint256 => SaleDetails) private _tokenSaleDetails; + // Sales details indexed by sale index. + SaleDetails[] private _saleDetails; + // tokenId => saleIndex => quantity minted + mapping(uint256 => mapping(uint256 => uint256)) private _tokensMintedPerSale; /** * Initialize the contract. @@ -49,85 +48,64 @@ contract ERC1155Sale is IERC1155Sale, WithdrawControlled, MerkleProofSingleUse, _initialized = true; } - /** - * Checks if the current block.timestamp is out of the give timestamp range. - * @param _startTime Earliest acceptable timestamp (inclusive). - * @param _endTime Latest acceptable timestamp (exclusive). - * @dev A zero endTime value is always considered out of bounds. - */ - function _blockTimeOutOfBounds(uint256 _startTime, uint256 _endTime) private view returns (bool) { - // 0 end time indicates inactive sale. - return _endTime == 0 || block.timestamp < _startTime || block.timestamp >= _endTime; // solhint-disable-line not-rely-on-time - } - /** * Checks the sale is active, valid and takes payment. * @param _tokenIds Token IDs to mint. * @param _amounts Amounts of tokens to mint. + * @param _saleIndex Sale index to mint from. * @param _expectedPaymentToken ERC20 token address to accept payment in. address(0) indicates ETH. * @param _maxTotal Maximum amount of payment tokens. * @param _proof Merkle proof for allowlist minting. */ function _validateMint( - uint256[] memory _tokenIds, - uint256[] memory _amounts, + uint256[] calldata _tokenIds, + uint256[] calldata _amounts, + uint256 _saleIndex, address _expectedPaymentToken, uint256 _maxTotal, bytes32[] calldata _proof ) private { - uint256 lastTokenId; uint256 totalCost; - uint256 totalAmount; - SaleDetails memory gSaleDetails = _globalSaleDetails; - bool globalSaleInactive = _blockTimeOutOfBounds(gSaleDetails.startTime, gSaleDetails.endTime); - bool globalMerkleCheckRequired = false; + // Find the sale details for the token + if (_saleIndex >= _saleDetails.length) { + revert SaleDetailsNotFound(_saleIndex); + } + SaleDetails memory details = _saleDetails[_saleIndex]; + for (uint256 i; i < _tokenIds.length; i++) { uint256 tokenId = _tokenIds[i]; - // Test tokenIds ordering - if (i != 0 && lastTokenId >= tokenId) { - revert InvalidTokenIds(); + + // Check if token is within the sale range + if (tokenId < details.minTokenId || tokenId > details.maxTokenId) { + revert InvalidSaleDetails(); + } + + // Check if sale is active + // solhint-disable-next-line not-rely-on-time + if (block.timestamp < details.startTime || block.timestamp > details.endTime) { + revert SaleInactive(); } - lastTokenId = tokenId; uint256 amount = _amounts[i]; - // Active sale test - SaleDetails memory saleDetails = _tokenSaleDetails[tokenId]; - bool tokenSaleInactive = _blockTimeOutOfBounds(saleDetails.startTime, saleDetails.endTime); - if (tokenSaleInactive) { - // Prefer token sale - if (globalSaleInactive) { - // Both sales inactive - revert SaleInactive(tokenId); - } - // Use global sale details - if (_globalSaleDetails.remainingSupply < amount) { - revert InsufficientSupply(_globalSaleDetails.remainingSupply, amount); - } - globalMerkleCheckRequired = true; - totalCost += gSaleDetails.cost * amount; - _globalSaleDetails.remainingSupply -= amount; - } else { - // Use token sale details - if (saleDetails.remainingSupply < amount) { - revert InsufficientSupply(saleDetails.remainingSupply, amount); - } - requireMerkleProof(saleDetails.merkleRoot, _proof, msg.sender, bytes32(tokenId)); - totalCost += saleDetails.cost * amount; - _tokenSaleDetails[tokenId].remainingSupply -= amount; + // Check supply + uint256 minted = _tokensMintedPerSale[tokenId][_saleIndex]; + if (amount > details.supply - minted) { + revert InsufficientSupply(details.supply - minted, amount); } - totalAmount += amount; - } - if (globalMerkleCheckRequired) { - // Check it once outside the loop only when required - requireMerkleProof(gSaleDetails.merkleRoot, _proof, msg.sender, bytes32(type(uint256).max)); + // Check merkle proof + requireMerkleProof(details.merkleRoot, _proof, msg.sender, bytes32(tokenId)); + + // Update supply and calculate cost + _tokensMintedPerSale[tokenId][_saleIndex] = minted + amount; + totalCost += details.cost * amount; } - if (_expectedPaymentToken != _paymentToken) { + if (_expectedPaymentToken != details.paymentToken) { // Caller expected different payment token - revert InsufficientPayment(_paymentToken, totalCost, 0); + revert InsufficientPayment(details.paymentToken, totalCost, 0); } if (_maxTotal < totalCost) { // Caller expected to pay less @@ -152,24 +130,15 @@ contract ERC1155Sale is IERC1155Sale, WithdrawControlled, MerkleProofSingleUse, // Minting // - /** - * Mint tokens. - * @param to Address to mint tokens to. - * @param tokenIds Token IDs to mint. - * @param amounts Amounts of tokens to mint. - * @param data Data to pass if receiver is contract. - * @param expectedPaymentToken ERC20 token address to accept payment in. address(0) indicates ETH. - * @param maxTotal Maximum amount of payment tokens. - * @param proof Merkle proof for allowlist minting. - * @notice Sale must be active for all tokens. - * @dev tokenIds must be sorted ascending without duplicates. - * @dev An empty proof is supplied when no proof is required. - */ + /// @inheritdoc IERC1155Sale + /// @notice Sale must be active for all tokens. + /// @dev An empty proof is supplied when no proof is required. function mint( address to, - uint256[] memory tokenIds, - uint256[] memory amounts, - bytes memory data, + uint256[] calldata tokenIds, + uint256[] calldata amounts, + bytes calldata data, + uint256 saleIndex, address expectedPaymentToken, uint256 maxTotal, bytes32[] calldata proof @@ -177,186 +146,86 @@ contract ERC1155Sale is IERC1155Sale, WithdrawControlled, MerkleProofSingleUse, if (tokenIds.length != amounts.length) { revert InvalidTokenIds(); } - _validateMint(tokenIds, amounts, expectedPaymentToken, maxTotal, proof); + _validateMint(tokenIds, amounts, saleIndex, expectedPaymentToken, maxTotal, proof); IERC1155ItemsFunctions(_items).batchMint(to, tokenIds, amounts, data); - emit ItemsMinted(to, tokenIds, amounts); + emit ItemsMinted(to, tokenIds, amounts, saleIndex); } // // Admin // - /** - * Set the payment token. - * @param paymentTokenAddr The ERC20 token address to accept payment in. address(0) indicates ETH. - * @dev This should be set before the sale starts. - */ - function setPaymentToken( - address paymentTokenAddr - ) public onlyRole(MINT_ADMIN_ROLE) { - _paymentToken = paymentTokenAddr; + /// @inheritdoc IERC1155Sale + function addSaleDetails( + SaleDetails calldata details + ) public onlyRole(MINT_ADMIN_ROLE) returns (uint256 saleIndex) { + _validateSaleDetails(details); + + saleIndex = _saleDetails.length; + _saleDetails.push(details); + + emit SaleDetailsAdded(saleIndex, details); } - /** - * Set the global sale details. - * @param cost The amount of payment tokens to accept for each token minted. - * @param remainingSupply The maximum number of tokens that can be minted by the items contract. - * @param startTime The start time of the sale. Tokens cannot be minted before this time. - * @param endTime The end time of the sale. Tokens cannot be minted after this time. - * @param merkleRoot The merkle root for allowlist minting. - * @dev A zero end time indicates an inactive sale. - * @notice The payment token is set globally. - */ - function setGlobalSaleDetails( - uint256 cost, - uint256 remainingSupply, - uint64 startTime, - uint64 endTime, - bytes32 merkleRoot - ) public onlyRole(MINT_ADMIN_ROLE) { - // solhint-disable-next-line not-rely-on-time - if (endTime < startTime || endTime <= block.timestamp) { - revert InvalidSaleDetails(); + /// @inheritdoc IERC1155Sale + function updateSaleDetails(uint256 saleIndex, SaleDetails calldata details) public onlyRole(MINT_ADMIN_ROLE) { + if (saleIndex >= _saleDetails.length) { + revert SaleDetailsNotFound(saleIndex); } - if (remainingSupply == 0) { - revert InvalidSaleDetails(); - } - _globalSaleDetails = SaleDetails(cost, remainingSupply, startTime, endTime, merkleRoot); - emit GlobalSaleDetailsUpdated(cost, remainingSupply, startTime, endTime, merkleRoot); + _validateSaleDetails(details); + + _saleDetails[saleIndex] = details; + + emit SaleDetailsUpdated(saleIndex, details); } - /** - * Set the sale details for an individual token. - * @param tokenId The token ID to set the sale details for. - * @param cost The amount of payment tokens to accept for each token minted. - * @param remainingSupply The maximum number of tokens that can be minted by this contract. - * @param startTime The start time of the sale. Tokens cannot be minted before this time. - * @param endTime The end time of the sale. Tokens cannot be minted after this time. - * @param merkleRoot The merkle root for allowlist minting. - * @dev A zero end time indicates an inactive sale. - * @notice The payment token is set globally. - */ - function setTokenSaleDetails( - uint256 tokenId, - uint256 cost, - uint256 remainingSupply, - uint64 startTime, - uint64 endTime, - bytes32 merkleRoot - ) public onlyRole(MINT_ADMIN_ROLE) { - // solhint-disable-next-line not-rely-on-time - if (endTime < startTime || endTime <= block.timestamp) { + function _validateSaleDetails( + SaleDetails calldata details + ) private pure { + if (details.maxTokenId < details.minTokenId) { revert InvalidSaleDetails(); } - if (remainingSupply == 0) { + if (details.supply == 0) { revert InvalidSaleDetails(); } - _tokenSaleDetails[tokenId] = SaleDetails(cost, remainingSupply, startTime, endTime, merkleRoot); - emit TokenSaleDetailsUpdated(tokenId, cost, remainingSupply, startTime, endTime, merkleRoot); - } - - /** - * Set the sale details for a batch of tokens. - * @param tokenIds The token IDs to set the sale details for. - * @param costs The amount of payment tokens to accept for each token minted. - * @param remainingSupplies The maximum number of tokens that can be minted by this contract. - * @param startTimes The start time of the sale. Tokens cannot be minted before this time. - * @param endTimes The end time of the sale. Tokens cannot be minted after this time. - * @param merkleRoots The merkle root for allowlist minting. - * @dev A zero end time indicates an inactive sale. - * @notice The payment token is set globally. - * @dev tokenIds must be sorted ascending without duplicates. - */ - function setTokenSaleDetailsBatch( - uint256[] calldata tokenIds, - uint256[] calldata costs, - uint256[] calldata remainingSupplies, - uint64[] calldata startTimes, - uint64[] calldata endTimes, - bytes32[] calldata merkleRoots - ) public onlyRole(MINT_ADMIN_ROLE) { - if ( - tokenIds.length != costs.length || tokenIds.length != remainingSupplies.length - || tokenIds.length != startTimes.length || tokenIds.length != endTimes.length - || tokenIds.length != merkleRoots.length - ) { + if (details.endTime < details.startTime) { revert InvalidSaleDetails(); } - - uint256 lastTokenId; - for (uint256 i = 0; i < tokenIds.length; i++) { - uint256 tokenId = tokenIds[i]; - if (i != 0 && lastTokenId >= tokenId) { - revert InvalidTokenIds(); - } - lastTokenId = tokenId; - - // solhint-disable-next-line not-rely-on-time - if (endTimes[i] < startTimes[i] || endTimes[i] <= block.timestamp) { - revert InvalidSaleDetails(); - } - if (remainingSupplies[i] == 0) { - revert InvalidSaleDetails(); - } - _tokenSaleDetails[tokenId] = - SaleDetails(costs[i], remainingSupplies[i], startTimes[i], endTimes[i], merkleRoots[i]); - emit TokenSaleDetailsUpdated( - tokenId, costs[i], remainingSupplies[i], startTimes[i], endTimes[i], merkleRoots[i] - ); - } } // // Views // - /** - * Get global sales details. - * @return Sale details. - * @notice Global sales details apply to all tokens. - * @notice Global sales details are overriden when token sale is active. - */ - function globalSaleDetails() external view returns (SaleDetails memory) { - return _globalSaleDetails; + /// @inheritdoc IERC1155Sale + function saleDetailsCount() external view returns (uint256) { + return _saleDetails.length; } - /** - * Get token sale details. - * @param tokenId Token ID to get sale details for. - * @return Sale details. - * @notice Token sale details override global sale details. - */ - function tokenSaleDetails( - uint256 tokenId + /// @inheritdoc IERC1155Sale + function saleDetails( + uint256 saleIndex ) external view returns (SaleDetails memory) { - return _tokenSaleDetails[tokenId]; + if (saleIndex >= _saleDetails.length) { + revert SaleDetailsNotFound(saleIndex); + } + return _saleDetails[saleIndex]; } - /** - * Get sale details for multiple tokens. - * @param tokenIds Array of token IDs to retrieve sale details for. - * @return Array of sale details corresponding to each token ID. - * @notice Each token's sale details override the global sale details if set. - */ - function tokenSaleDetailsBatch( - uint256[] calldata tokenIds + /// @inheritdoc IERC1155Sale + function saleDetailsBatch( + uint256[] calldata saleIndexes ) external view returns (SaleDetails[] memory) { - SaleDetails[] memory details = new SaleDetails[](tokenIds.length); - for (uint256 i = 0; i < tokenIds.length; i++) { - details[i] = _tokenSaleDetails[tokenIds[i]]; + SaleDetails[] memory details = new SaleDetails[](saleIndexes.length); + for (uint256 i = 0; i < saleIndexes.length; i++) { + if (saleIndexes[i] >= _saleDetails.length) { + revert SaleDetailsNotFound(saleIndexes[i]); + } + details[i] = _saleDetails[saleIndexes[i]]; } return details; } - /** - * Get payment token. - * @return Payment token address. - * @notice address(0) indicates payment in ETH. - */ - function paymentToken() external view returns (address) { - return _paymentToken; - } - /** * Check interface support. * @param interfaceId Interface id @@ -365,8 +234,7 @@ contract ERC1155Sale is IERC1155Sale, WithdrawControlled, MerkleProofSingleUse, function supportsInterface( bytes4 interfaceId ) public view virtual override(WithdrawControlled, SignalsImplicitModeControlled) returns (bool) { - return type(IERC1155SaleFunctions).interfaceId == interfaceId - || WithdrawControlled.supportsInterface(interfaceId) + return type(IERC1155Sale).interfaceId == interfaceId || WithdrawControlled.supportsInterface(interfaceId) || SignalsImplicitModeControlled.supportsInterface(interfaceId); } diff --git a/src/tokens/ERC1155/utility/sale/ERC1155SaleFactory.sol b/src/tokens/ERC1155/utility/sale/ERC1155SaleFactory.sol index 25c7f8ab..876dbce0 100644 --- a/src/tokens/ERC1155/utility/sale/ERC1155SaleFactory.sol +++ b/src/tokens/ERC1155/utility/sale/ERC1155SaleFactory.sol @@ -23,13 +23,14 @@ contract ERC1155SaleFactory is IERC1155SaleFactory, SequenceProxyFactory { /// @inheritdoc IERC1155SaleFactoryFunctions function deploy( + uint256 nonce, address proxyOwner, address tokenOwner, address items, address implicitModeValidator, bytes32 implicitModeProjectId ) external returns (address proxyAddr) { - bytes32 salt = keccak256(abi.encode(tokenOwner, items, implicitModeValidator, implicitModeProjectId)); + bytes32 salt = keccak256(abi.encode(nonce, tokenOwner, items, implicitModeValidator, implicitModeProjectId)); proxyAddr = _createProxy(salt, proxyOwner, ""); ERC1155Sale(proxyAddr).initialize(tokenOwner, items, implicitModeValidator, implicitModeProjectId); emit ERC1155SaleDeployed(proxyAddr); @@ -38,13 +39,14 @@ contract ERC1155SaleFactory is IERC1155SaleFactory, SequenceProxyFactory { /// @inheritdoc IERC1155SaleFactoryFunctions function determineAddress( + uint256 nonce, address proxyOwner, address tokenOwner, address items, address implicitModeValidator, bytes32 implicitModeProjectId ) external view returns (address proxyAddr) { - bytes32 salt = keccak256(abi.encode(tokenOwner, items, implicitModeValidator, implicitModeProjectId)); + bytes32 salt = keccak256(abi.encode(nonce, tokenOwner, items, implicitModeValidator, implicitModeProjectId)); return _computeProxyAddress(salt, proxyOwner, ""); } diff --git a/src/tokens/ERC1155/utility/sale/IERC1155Sale.sol b/src/tokens/ERC1155/utility/sale/IERC1155Sale.sol index 9436cc70..919545e9 100644 --- a/src/tokens/ERC1155/utility/sale/IERC1155Sale.sol +++ b/src/tokens/ERC1155/utility/sale/IERC1155Sale.sol @@ -1,54 +1,74 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.19; -interface IERC1155SaleFunctions { +interface IERC1155Sale { + /** + * Sale details. + * @param minTokenId Minimum token ID for this sale. (Inclusive) + * @param maxTokenId Maximum token ID for this sale. (Inclusive) + * @param cost Cost per token. + * @param paymentToken ERC20 token address to accept payment in. address(0) indicates payment in ETH. + * @param supply Maximum number of tokens that can be minted (per token ID). + * @param startTime Start time of the sale. (Inclusive) + * @param endTime End time of the sale. (Inclusive) + * @param merkleRoot Merkle root for allowlist minting. 0 indicates no proof required. + */ struct SaleDetails { + uint256 minTokenId; + uint256 maxTokenId; uint256 cost; - uint256 remainingSupply; + address paymentToken; + uint256 supply; uint64 startTime; - uint64 endTime; // 0 end time indicates sale inactive - bytes32 merkleRoot; // Root of allowed addresses + uint64 endTime; + bytes32 merkleRoot; } /** - * Get global sales details. - * @return Sale details. - * @notice Global sales details apply to all tokens. - * @notice Global sales details are overriden when token sale is active. + * Get the total number of sale details. + * @return Total number of sale details. */ - function globalSaleDetails() external view returns (SaleDetails memory); + function saleDetailsCount() external view returns (uint256); /** - * Get token sale details. - * @param tokenId Token ID to get sale details for. - * @return Sale details. - * @notice Token sale details override global sale details. + * Get sale details. + * @param saleIndex Index of the sale details to get. + * @return details Sale details. */ - function tokenSaleDetails( - uint256 tokenId - ) external view returns (SaleDetails memory); + function saleDetails( + uint256 saleIndex + ) external view returns (SaleDetails memory details); /** - * Get sale details for multiple tokens. - * @param tokenIds Array of token IDs to retrieve sale details for. - * @return Array of sale details corresponding to each token ID. - * @notice Each token's sale details override the global sale details if set. + * Get sale details for multiple sale indexes. + * @param saleIndexes Array of sale indexes to retrieve sale details for. + * @return details Array of sale details corresponding to each sale index. */ - function tokenSaleDetailsBatch( - uint256[] calldata tokenIds - ) external view returns (SaleDetails[] memory); + function saleDetailsBatch( + uint256[] calldata saleIndexes + ) external view returns (SaleDetails[] memory details); /** - * Get payment token. - * @return Payment token address. - * @notice address(0) indicates payment in ETH. + * Add new sale details. + * @param details Sale details to add. + * @return saleIndex Index of the newly added sale details. */ - function paymentToken() external view returns (address); + function addSaleDetails( + SaleDetails calldata details + ) external returns (uint256 saleIndex); + + /** + * Update existing sale details. + * @param saleIndex Index of the sale details to update. + * @param details Sale details to update. + */ + function updateSaleDetails(uint256 saleIndex, SaleDetails calldata details) external; /** * Mint tokens. * @param to Address to mint tokens to. + * @param saleIndex Index of the token sale details to mint. * @param tokenIds Token IDs to mint. * @param amounts Amounts of tokens to mint. * @param data Data to pass if receiver is contract. @@ -61,25 +81,37 @@ interface IERC1155SaleFunctions { */ function mint( address to, - uint256[] memory tokenIds, - uint256[] memory amounts, - bytes memory data, + uint256[] calldata tokenIds, + uint256[] calldata amounts, + bytes calldata data, + uint256 saleIndex, address paymentToken, uint256 maxTotal, bytes32[] calldata proof ) external payable; -} + /** + * Emitted when sale details are added. + * @param saleIndex Index of the sale details that were added. + * @param details Sale details that were added. + */ + event SaleDetailsAdded(uint256 saleIndex, SaleDetails details); -interface IERC1155SaleSignals { + /** + * Emitted when sale details are updated. + * @param saleIndex Index of the sale details that were updated. + * @param details Sale details that were updated. + */ + event SaleDetailsUpdated(uint256 saleIndex, SaleDetails details); - event GlobalSaleDetailsUpdated( - uint256 cost, uint256 remainingSupply, uint64 startTime, uint64 endTime, bytes32 merkleRoot - ); - event TokenSaleDetailsUpdated( - uint256 tokenId, uint256 cost, uint256 remainingSupply, uint64 startTime, uint64 endTime, bytes32 merkleRoot - ); - event ItemsMinted(address to, uint256[] tokenIds, uint256[] amounts); + /** + * Emitted when tokens are minted. + * @param to Address that minted the tokens. + * @param tokenIds Token IDs that were minted. + * @param amounts Amounts of tokens that were minted. + * @param saleIndex Index of the sale details that were minted. + */ + event ItemsMinted(address to, uint256[] tokenIds, uint256[] amounts, uint256 saleIndex); /** * Contract already initialized. @@ -92,15 +124,14 @@ interface IERC1155SaleSignals { error InvalidSaleDetails(); /** - * Sale is not active globally. + * Sale details index does not exist. */ - error GlobalSaleInactive(); + error SaleDetailsNotFound(uint256 index); /** * Sale is not active. - * @param tokenId Invalid Token ID. */ - error SaleInactive(uint256 tokenId); + error SaleInactive(); /** * Insufficient tokens for payment. @@ -123,5 +154,3 @@ interface IERC1155SaleSignals { error InsufficientSupply(uint256 remainingSupply, uint256 amount); } - -interface IERC1155Sale is IERC1155SaleFunctions, IERC1155SaleSignals { } diff --git a/src/tokens/ERC1155/utility/sale/IERC1155SaleFactory.sol b/src/tokens/ERC1155/utility/sale/IERC1155SaleFactory.sol index 693d13dd..a022fe4e 100644 --- a/src/tokens/ERC1155/utility/sale/IERC1155SaleFactory.sol +++ b/src/tokens/ERC1155/utility/sale/IERC1155SaleFactory.sol @@ -5,6 +5,7 @@ interface IERC1155SaleFactoryFunctions { /** * Creates an ERC-1155 Sale proxy contract + * @param nonce Extra salt to add to the salt used to compute the address * @param proxyOwner The owner of the ERC-1155 Sale proxy * @param tokenOwner The owner of the ERC-1155 Sale implementation * @param items The ERC-1155 Items contract address @@ -14,6 +15,7 @@ interface IERC1155SaleFactoryFunctions { * @notice The deployed contract must be granted the MINTER_ROLE on the ERC-1155 Items contract. */ function deploy( + uint256 nonce, address proxyOwner, address tokenOwner, address items, @@ -23,6 +25,7 @@ interface IERC1155SaleFactoryFunctions { /** * Computes the address of a proxy instance. + * @param nonce Extra salt to add to the salt used to compute the address * @param proxyOwner The owner of the ERC-1155 Sale proxy * @param tokenOwner The owner of the ERC-1155 Sale implementation * @param items The ERC-1155 Items contract address @@ -31,6 +34,7 @@ interface IERC1155SaleFactoryFunctions { * @return proxyAddr The address of the ERC-1155 Sale Proxy */ function determineAddress( + uint256 nonce, address proxyOwner, address tokenOwner, address items, diff --git a/test/tokens/ERC1155/utility/sale/ERC1155SaleBase.t.sol b/test/tokens/ERC1155/utility/sale/ERC1155SaleBase.t.sol index fa9cfab8..2af0c3a3 100644 --- a/test/tokens/ERC1155/utility/sale/ERC1155SaleBase.t.sol +++ b/test/tokens/ERC1155/utility/sale/ERC1155SaleBase.t.sol @@ -6,9 +6,8 @@ import { ERC20Mock } from "../../../../_mocks/ERC20Mock.sol"; import { IERC1155Supply, IERC1155SupplySignals } from "src/tokens/ERC1155/extensions/supply/IERC1155Supply.sol"; import { ERC1155Items } from "src/tokens/ERC1155/presets/items/ERC1155Items.sol"; -import { ERC1155Sale } from "src/tokens/ERC1155/utility/sale/ERC1155Sale.sol"; +import { ERC1155Sale, IERC1155Sale } from "src/tokens/ERC1155/utility/sale/ERC1155Sale.sol"; import { ERC1155SaleFactory } from "src/tokens/ERC1155/utility/sale/ERC1155SaleFactory.sol"; -import { IERC1155SaleFunctions, IERC1155SaleSignals } from "src/tokens/ERC1155/utility/sale/IERC1155Sale.sol"; import { IAccessControl } from "openzeppelin-contracts/contracts/access/IAccessControl.sol"; import { Strings } from "openzeppelin-contracts/contracts/utils/Strings.sol"; @@ -18,7 +17,7 @@ import { ISignalsImplicitMode } from "signals-implicit-mode/src/helper/SignalsIm // solhint-disable not-rely-on-time -contract ERC1155SaleBaseTest is TestHelper, IERC1155SaleSignals, IERC1155SupplySignals { +contract ERC1155SaleBaseTest is TestHelper, IERC1155SupplySignals { // Redeclare events event TransferSingle( @@ -50,14 +49,14 @@ contract ERC1155SaleBaseTest is TestHelper, IERC1155SaleSignals, IERC1155SupplyS function setUpFromFactory() public { ERC1155SaleFactory factory = new ERC1155SaleFactory(address(this)); - sale = ERC1155Sale(factory.deploy(proxyOwner, address(this), address(token), address(0), bytes32(0))); + sale = ERC1155Sale(factory.deploy(0, proxyOwner, address(this), address(token), address(0), bytes32(0))); token.grantRole(keccak256("MINTER_ROLE"), address(sale)); } function testSupportsInterface() public view { assertTrue(sale.supportsInterface(type(IERC165).interfaceId)); assertTrue(sale.supportsInterface(type(IAccessControl).interfaceId)); - assertTrue(sale.supportsInterface(type(IERC1155SaleFunctions).interfaceId)); + assertTrue(sale.supportsInterface(type(IERC1155Sale).interfaceId)); assertTrue(sale.supportsInterface(type(ISignalsImplicitMode).interfaceId)); } @@ -84,16 +83,13 @@ contract ERC1155SaleBaseTest is TestHelper, IERC1155SaleSignals, IERC1155SupplyS checkSelectorCollision(0xed4c2ac7); // setImplicitModeProjectId(bytes32) checkSelectorCollision(0x0bb310de); // setImplicitModeValidator(address) checkSelectorCollision(0x6a326ab1); // setPaymentToken(address) - checkSelectorCollision(0x4f651ccd); // setTokenSaleDetails(uint256,uint256,uint256,uint64,uint64,bytes32) - checkSelectorCollision(0xf07f04ff); // setTokenSaleDetailsBatch(uint256[],uint256[],uint256[],uint64[],uint64[],bytes32[]) checkSelectorCollision(0x01ffc9a7); // supportsInterface(bytes4) - checkSelectorCollision(0x0869678c); // tokenSaleDetails(uint256) - checkSelectorCollision(0xff81434e); // tokenSaleDetailsBatch(uint256[]) checkSelectorCollision(0x44004cc1); // withdrawERC20(address,address,uint256) checkSelectorCollision(0x4782f779); // withdrawETH(address,uint256) } function testFactoryDetermineAddress( + uint256 nonce, address _proxyOwner, address tokenOwner, address items, @@ -104,131 +100,159 @@ contract ERC1155SaleBaseTest is TestHelper, IERC1155SaleSignals, IERC1155SupplyS vm.assume(tokenOwner != address(0)); ERC1155SaleFactory factory = new ERC1155SaleFactory(address(this)); address deployedAddr = - factory.deploy(_proxyOwner, tokenOwner, items, implicitModeValidator, implicitModeProjectId); - address predictedAddr = - factory.determineAddress(_proxyOwner, tokenOwner, items, implicitModeValidator, implicitModeProjectId); + factory.deploy(nonce, _proxyOwner, tokenOwner, items, implicitModeValidator, implicitModeProjectId); + address predictedAddr = factory.determineAddress( + nonce, _proxyOwner, tokenOwner, items, implicitModeValidator, implicitModeProjectId + ); assertEq(deployedAddr, predictedAddr); } // - // Setter and getter + // Admin // - function testGlobalSaleDetails( - uint256 cost, - uint256 remainingSupply, - uint64 startTime, - uint64 endTime, - bytes32 merkleRoot + function test_addSaleDetails_success( + IERC1155Sale.SaleDetails[] memory beforeDetails, + IERC1155Sale.SaleDetails memory details ) public { - remainingSupply = bound(remainingSupply, 1, type(uint256).max); - endTime = uint64(bound(endTime, block.timestamp + 1, type(uint64).max)); - startTime = uint64(bound(startTime, 0, endTime)); - - // Setter - vm.expectEmit(true, true, true, true, address(sale)); - emit GlobalSaleDetailsUpdated(cost, remainingSupply, startTime, endTime, merkleRoot); - sale.setGlobalSaleDetails(cost, remainingSupply, startTime, endTime, merkleRoot); - - // Getter - IERC1155SaleFunctions.SaleDetails memory _saleDetails = sale.globalSaleDetails(); - assertEq(cost, _saleDetails.cost); - assertEq(remainingSupply, _saleDetails.remainingSupply); - assertEq(startTime, _saleDetails.startTime); - assertEq(endTime, _saleDetails.endTime); - assertEq(merkleRoot, _saleDetails.merkleRoot); + for (uint256 i = 0; i < beforeDetails.length; i++) { + sale.addSaleDetails(validSaleDetails(0, beforeDetails[i])); + } + + details = validSaleDetails(0, details); + vm.expectEmit(true, true, true, true); + emit IERC1155Sale.SaleDetailsAdded(beforeDetails.length, details); + uint256 saleIndex = sale.addSaleDetails(details); + + assertEq(sale.saleDetailsCount(), beforeDetails.length + 1); + IERC1155Sale.SaleDetails memory actual = sale.saleDetails(saleIndex); + _compareSaleDetails(actual, details); } - function testTokenSaleDetails( - uint256 tokenId, - uint256 cost, - uint256 remainingSupply, - uint64 startTime, - uint64 endTime, - bytes32 merkleRoot + function test_addSaleDetails_fail_invalidTokenId( + IERC1155Sale.SaleDetails memory details ) public { - remainingSupply = bound(remainingSupply, 1, type(uint256).max); - endTime = uint64(bound(endTime, block.timestamp + 1, type(uint64).max)); - startTime = uint64(bound(startTime, 0, endTime)); - - // Setter - vm.expectEmit(true, true, true, true, address(sale)); - emit TokenSaleDetailsUpdated(tokenId, cost, remainingSupply, startTime, endTime, merkleRoot); - sale.setTokenSaleDetails(tokenId, cost, remainingSupply, startTime, endTime, merkleRoot); - - // Getter - IERC1155SaleFunctions.SaleDetails memory _saleDetails = sale.tokenSaleDetails(tokenId); - assertEq(cost, _saleDetails.cost); - assertEq(remainingSupply, _saleDetails.remainingSupply); - assertEq(startTime, _saleDetails.startTime); - assertEq(endTime, _saleDetails.endTime); - assertEq(merkleRoot, _saleDetails.merkleRoot); + details = validSaleDetails(0, details); + details.minTokenId = bound(details.minTokenId, 1, type(uint256).max); + details.maxTokenId = bound(details.maxTokenId, 0, details.minTokenId - 1); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidSaleDetails.selector)); + sale.addSaleDetails(details); } - function testTokenSaleDetailsBatch( - uint256[] memory tokenIds, - uint256[] memory costs, - uint256[] memory remainingSupplys, - uint64[] memory startTimes, - uint64[] memory endTimes, - bytes32[] memory merkleRoots + function test_addSaleDetails_fail_invalidSupply( + IERC1155Sale.SaleDetails memory details ) public { - uint256 minLength = tokenIds.length; - minLength = minLength > costs.length ? costs.length : minLength; - minLength = minLength > remainingSupplys.length ? remainingSupplys.length : minLength; - minLength = minLength > startTimes.length ? startTimes.length : minLength; - minLength = minLength > endTimes.length ? endTimes.length : minLength; - minLength = minLength > merkleRoots.length ? merkleRoots.length : minLength; - minLength = minLength > 5 ? 5 : minLength; // Max 5 - vm.assume(minLength > 0); - // solhint-disable-next-line no-inline-assembly - assembly { - mstore(tokenIds, minLength) - mstore(costs, minLength) - mstore(remainingSupplys, minLength) - mstore(startTimes, minLength) - mstore(endTimes, minLength) - mstore(merkleRoots, minLength) - } + details = validSaleDetails(0, details); + details.supply = 0; + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidSaleDetails.selector)); + sale.addSaleDetails(details); + } - // Sort tokenIds ascending and ensure no duplicates - for (uint256 i = 0; i < minLength; i++) { - for (uint256 j = i + 1; j < minLength; j++) { - if (tokenIds[i] > tokenIds[j]) { - (tokenIds[i], tokenIds[j]) = (tokenIds[j], tokenIds[i]); - } - } - } - for (uint256 i = 0; i < minLength - 1; i++) { - vm.assume(tokenIds[i] != tokenIds[i + 1]); - } + function test_addSaleDetails_fail_invalidStartTime( + IERC1155Sale.SaleDetails memory details + ) public { + details = validSaleDetails(0, details); + details.startTime = uint64(bound(details.startTime, 1, type(uint64).max)); + details.endTime = uint64(bound(details.endTime, 0, details.startTime - 1)); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidSaleDetails.selector)); + sale.addSaleDetails(details); + } - // Bound values - for (uint256 i = 0; i < minLength; i++) { - remainingSupplys[i] = bound(remainingSupplys[i], 1, type(uint256).max); - endTimes[i] = uint64(bound(endTimes[i], block.timestamp + 1, type(uint64).max)); - startTimes[i] = uint64(bound(startTimes[i], 0, endTimes[i])); + function test_updateSaleDetails_success( + IERC1155Sale.SaleDetails[] memory beforeDetails, + IERC1155Sale.SaleDetails memory newDetails, + uint256 saleIndex + ) public { + vm.assume(beforeDetails.length > 0); + saleIndex = bound(saleIndex, 0, beforeDetails.length - 1); + for (uint256 i = 0; i < beforeDetails.length; i++) { + sale.addSaleDetails(validSaleDetails(0, beforeDetails[i])); } + uint256 beforeUpdateCount = sale.saleDetailsCount(); + + newDetails = validSaleDetails(0, newDetails); + + vm.expectEmit(true, true, true, true); + emit IERC1155Sale.SaleDetailsUpdated(saleIndex, newDetails); + sale.updateSaleDetails(saleIndex, newDetails); + + assertEq(sale.saleDetailsCount(), beforeUpdateCount); // Unchanged + IERC1155Sale.SaleDetails memory actual = sale.saleDetails(saleIndex); + _compareSaleDetails(actual, newDetails); + } - // Setter - for (uint256 i = 0; i < minLength; i++) { - vm.expectEmit(true, true, true, true, address(sale)); - emit TokenSaleDetailsUpdated( - tokenIds[i], costs[i], remainingSupplys[i], startTimes[i], endTimes[i], merkleRoots[i] - ); + function test_updateSaleDetails_fail_notFound( + IERC1155Sale.SaleDetails memory newDetails, + uint256 saleIndex + ) public { + vm.assume(saleIndex >= sale.saleDetailsCount()); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.SaleDetailsNotFound.selector, saleIndex)); + sale.updateSaleDetails(saleIndex, newDetails); + } + + function test_updateSaleDetails_fail_invalidTokenId( + IERC1155Sale.SaleDetails memory details + ) public { + details = validSaleDetails(0, details); + uint256 saleIndex = sale.addSaleDetails(details); + details.minTokenId = bound(details.minTokenId, 1, type(uint256).max); + details.maxTokenId = bound(details.maxTokenId, 0, details.minTokenId - 1); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidSaleDetails.selector)); + sale.updateSaleDetails(saleIndex, details); + } + + function test_updateSaleDetails_fail_invalidSupply( + IERC1155Sale.SaleDetails memory details + ) public { + details = validSaleDetails(0, details); + uint256 saleIndex = sale.addSaleDetails(details); + details.supply = 0; + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidSaleDetails.selector)); + sale.updateSaleDetails(saleIndex, details); + } + + function test_updateSaleDetails_fail_invalidStartTime( + IERC1155Sale.SaleDetails memory details + ) public { + details = validSaleDetails(0, details); + uint256 saleIndex = sale.addSaleDetails(details); + details.startTime = uint64(bound(details.startTime, 1, type(uint64).max)); + details.endTime = uint64(bound(details.endTime, 0, details.startTime - 1)); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidSaleDetails.selector)); + sale.updateSaleDetails(saleIndex, details); + } + + function test_saleDetails_fail_notFound( + uint256 saleIndex + ) public { + vm.assume(saleIndex >= sale.saleDetailsCount()); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.SaleDetailsNotFound.selector, saleIndex)); + sale.saleDetails(saleIndex); + } + + function test_saleDetailsBatch( + IERC1155Sale.SaleDetails[] memory details + ) public { + uint256[] memory saleIndexes = new uint256[](details.length); + for (uint256 i = 0; i < details.length; i++) { + details[i] = validSaleDetails(0, details[i]); + saleIndexes[i] = sale.addSaleDetails(details[i]); } - sale.setTokenSaleDetailsBatch(tokenIds, costs, remainingSupplys, startTimes, endTimes, merkleRoots); - - // Getter - IERC1155SaleFunctions.SaleDetails[] memory _saleDetails = sale.tokenSaleDetailsBatch(tokenIds); - for (uint256 i = 0; i < minLength; i++) { - assertEq(costs[i], _saleDetails[i].cost); - assertEq(remainingSupplys[i], _saleDetails[i].remainingSupply); - assertEq(startTimes[i], _saleDetails[i].startTime); - assertEq(endTimes[i], _saleDetails[i].endTime); - assertEq(merkleRoots[i], _saleDetails[i].merkleRoot); + assertEq(sale.saleDetailsCount(), details.length); + IERC1155Sale.SaleDetails[] memory actual = sale.saleDetailsBatch(saleIndexes); + assertEq(actual.length, details.length); + for (uint256 i = 0; i < details.length; i++) { + _compareSaleDetails(actual[i], details[i]); } } + function test_saleDetailsBatch_fail_notFound( + uint256[] memory saleIndexes + ) public { + vm.assume(saleIndexes.length > 0); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.SaleDetailsNotFound.selector, saleIndexes[0])); + sale.saleDetailsBatch(saleIndexes); + } + // // Withdraw // @@ -295,12 +319,33 @@ contract ERC1155SaleBaseTest is TestHelper, IERC1155SaleSignals, IERC1155SupplyS _; } - modifier assumeSafe(address nonContract, uint256 tokenId, uint256 amount) { - assumeSafeAddress(nonContract); - vm.assume(nonContract != proxyOwner); - vm.assume(tokenId < 100); - vm.assume(amount > 0 && amount < 20); - _; + function validSaleDetails( + uint256 validTokenId, + IERC1155Sale.SaleDetails memory saleDetails + ) public view returns (IERC1155Sale.SaleDetails memory) { + saleDetails.minTokenId = bound(saleDetails.minTokenId, 0, validTokenId); + saleDetails.maxTokenId = bound(saleDetails.maxTokenId, validTokenId, type(uint256).max); + saleDetails.supply = bound(saleDetails.supply, 1, type(uint256).max); + saleDetails.cost = bound(saleDetails.cost, 0, type(uint256).max / saleDetails.supply); + saleDetails.startTime = uint64(bound(saleDetails.startTime, 0, block.timestamp)); + saleDetails.endTime = uint64(bound(saleDetails.endTime, block.timestamp, type(uint64).max)); + saleDetails.paymentToken = address(0); + saleDetails.merkleRoot = bytes32(0); + return saleDetails; + } + + function _compareSaleDetails( + IERC1155Sale.SaleDetails memory actual, + IERC1155Sale.SaleDetails memory expected + ) internal pure { + assertEq(actual.minTokenId, expected.minTokenId); + assertEq(actual.maxTokenId, expected.maxTokenId); + assertEq(actual.cost, expected.cost); + assertEq(actual.paymentToken, expected.paymentToken); + assertEq(actual.supply, expected.supply); + assertEq(actual.startTime, expected.startTime); + assertEq(actual.endTime, expected.endTime); + assertEq(actual.merkleRoot, expected.merkleRoot); } } diff --git a/test/tokens/ERC1155/utility/sale/ERC1155SaleMint.t.sol b/test/tokens/ERC1155/utility/sale/ERC1155SaleMint.t.sol index 5790a7cf..2dfb1d1b 100644 --- a/test/tokens/ERC1155/utility/sale/ERC1155SaleMint.t.sol +++ b/test/tokens/ERC1155/utility/sale/ERC1155SaleMint.t.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.19; +import { console } from "forge-std/console.sol"; + import { TestHelper } from "../../../../TestHelper.sol"; import { ERC20Mock } from "../../../../_mocks/ERC20Mock.sol"; @@ -8,12 +10,12 @@ import { IERC1155SupplySignals } from "src/tokens/ERC1155/extensions/supply/IERC import { ERC1155Items } from "src/tokens/ERC1155/presets/items/ERC1155Items.sol"; import { ERC1155Sale } from "src/tokens/ERC1155/utility/sale/ERC1155Sale.sol"; import { ERC1155SaleFactory } from "src/tokens/ERC1155/utility/sale/ERC1155SaleFactory.sol"; -import { IERC1155SaleSignals } from "src/tokens/ERC1155/utility/sale/IERC1155Sale.sol"; +import { IERC1155Sale } from "src/tokens/ERC1155/utility/sale/IERC1155Sale.sol"; import { IMerkleProofSingleUseSignals } from "src/tokens/common/IMerkleProofSingleUse.sol"; // solhint-disable not-rely-on-time -contract ERC1155SaleMintTest is TestHelper, IERC1155SaleSignals, IERC1155SupplySignals, IMerkleProofSingleUseSignals { +contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofSingleUseSignals { // Redeclare events event TransferSingle( @@ -26,13 +28,8 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SaleSignals, IERC1155SupplyS ERC1155Items private token; ERC1155Sale private sale; ERC20Mock private erc20; - uint256 private perTokenCost = 0.02 ether; - - address private proxyOwner; function setUp() public { - proxyOwner = makeAddr("proxyOwner"); - token = new ERC1155Items(); token.initialize(address(this), "test", "ipfs://", "ipfs://", address(this), 0, address(0), bytes32(0)); @@ -41,642 +38,423 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SaleSignals, IERC1155SupplyS token.grantRole(keccak256("MINTER_ROLE"), address(sale)); - vm.deal(address(this), 1e6 ether); + erc20 = new ERC20Mock(address(this)); } function setUpFromFactory() public { ERC1155SaleFactory factory = new ERC1155SaleFactory(address(this)); - sale = ERC1155Sale(factory.deploy(proxyOwner, address(this), address(token), address(0), bytes32(0))); + sale = ERC1155Sale(factory.deploy(0, address(this), address(this), address(token), address(0), bytes32(0))); token.grantRole(keccak256("MINTER_ROLE"), address(sale)); } // // Minting // + function test_mint_fail_invalidArrayLength(uint256[] memory tokenIds, uint256[] memory amounts) public { + vm.assume(tokenIds.length != amounts.length); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidTokenIds.selector)); + sale.mint(address(0), tokenIds, amounts, "", 0, address(0), 0, TestHelper.blankProof()); + } - // Minting denied when no sale active. - function testMintInactiveFail( - bool useFactory, - address mintTo, - uint256 tokenId, - uint256 amount - ) public withFactory(useFactory) { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); + function test_mint_fail_noSale(uint256 tokenId, uint256 amount) public { uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); - uint256 cost = amount * perTokenCost; - - vm.expectRevert(abi.encodeWithSelector(SaleInactive.selector, tokenId)); - sale.mint{ value: cost }(mintTo, tokenIds, amounts, "", address(0), cost, TestHelper.blankProof()); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.SaleDetailsNotFound.selector, 0)); + sale.mint(address(0), tokenIds, amounts, "", 0, address(0), 0, TestHelper.blankProof()); } - // Minting denied when sale is active but not for the token. - function testMintInactiveSingleFail( + function test_mint_success( bool useFactory, - address mintTo, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, uint256 amount - ) public withFactory(useFactory) { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - setTokenSaleActive(tokenId + 1); - uint256 cost = amount * perTokenCost; - - vm.expectRevert(abi.encodeWithSelector(SaleInactive.selector, tokenId)); - sale.mint{ value: cost }( - mintTo, - TestHelper.singleToArray(tokenId), - TestHelper.singleToArray(amount), - "", - address(0), - cost, - TestHelper.blankProof() - ); - } + ) public withFactory(useFactory) returns (uint256 saleIndex) { + assumeSafeAddress(recipient); + details = validSaleDetails(tokenId, details); + saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); + uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); + uint256[] memory amounts = TestHelper.singleToArray(amount); - // Minting denied when token sale is expired. - function testMintExpiredSingleFail( - bool useFactory, - address mintTo, - uint256 tokenId, - uint256 amount, - uint64 startTime, - uint64 endTime - ) public withFactory(useFactory) { - startTime = uint64(bound(startTime, 0, type(uint64).max - 1)); - endTime = uint64(bound(endTime, 0, type(uint64).max - 1)); - - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - if (startTime > endTime) { - uint64 temp = startTime; - startTime = endTime; - endTime = temp; - } - if (endTime == 0) { - endTime++; - } + uint256 expectedCost = details.cost * amount; + vm.deal(address(this), expectedCost); - vm.warp(uint256(endTime) - 1); - sale.setTokenSaleDetails(tokenId, perTokenCost, amount, startTime, endTime, ""); - vm.warp(uint256(endTime) + 1); - - uint256 cost = amount * perTokenCost; - - vm.expectRevert(abi.encodeWithSelector(SaleInactive.selector, tokenId)); - sale.mint{ value: cost }( - mintTo, - TestHelper.singleToArray(tokenId), - TestHelper.singleToArray(amount), - "", - address(0), - cost, - TestHelper.blankProof() + vm.expectEmit(true, true, true, true, address(token)); + emit TransferBatch(address(sale), address(0), recipient, tokenIds, amounts); + vm.expectEmit(true, true, true, true, address(sale)); + emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndex); + sale.mint{ value: expectedCost }( + recipient, tokenIds, amounts, "", saleIndex, address(0), expectedCost, TestHelper.blankProof() ); - } - // Minting denied when global sale is expired. - function testMintExpiredGlobalFail( - bool useFactory, - address mintTo, - uint256 tokenId, - uint256 amount, - uint64 startTime, - uint64 endTime - ) public withFactory(useFactory) { - startTime = uint64(bound(startTime, 0, type(uint64).max - 1)); - endTime = uint64(bound(endTime, 0, type(uint64).max - 1)); - - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - if (startTime > endTime) { - uint64 temp = startTime; - startTime = endTime; - endTime = temp; - } - if (endTime == 0) { - endTime++; - } + assertEq(address(sale).balance, expectedCost); - vm.warp(uint256(endTime) - 1); - sale.setGlobalSaleDetails(perTokenCost, amount, startTime, endTime, ""); - vm.warp(uint256(endTime) + 1); - - uint256 cost = amount * perTokenCost; - - vm.expectRevert(abi.encodeWithSelector(SaleInactive.selector, tokenId)); - sale.mint{ value: cost }( - mintTo, - TestHelper.singleToArray(tokenId), - TestHelper.singleToArray(amount), - "", - address(0), - cost, - TestHelper.blankProof() - ); + return saleIndex; } - // Minting denied when sale is active but not for all tokens in the group. - function testMintInactiveInGroupFail( + function test_mint_successERC20( bool useFactory, - address mintTo, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, uint256 amount - ) public withFactory(useFactory) { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - setTokenSaleActive(tokenId); - uint256[] memory tokenIds = new uint256[](2); - tokenIds[0] = tokenId; - tokenIds[1] = tokenId + 1; - uint256[] memory amounts = new uint256[](2); - amounts[0] = amount; - amounts[1] = amount; - uint256 cost = amount * perTokenCost * 2; - - vm.expectRevert(abi.encodeWithSelector(SaleInactive.selector, tokenId + 1)); - sale.mint{ value: cost }(mintTo, tokenIds, amounts, "", address(0), cost, TestHelper.blankProof()); - } + ) public withFactory(useFactory) returns (uint256 saleIndex) { + assumeSafeAddress(recipient); + details = validSaleDetails(tokenId, details); + details.paymentToken = address(erc20); + saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); + uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); + uint256[] memory amounts = TestHelper.singleToArray(amount); - // Minting denied when global supply exceeded. - function testMintGlobalSupplyExceeded( - bool useFactory, - address mintTo, - uint256 tokenId, - uint256 amount, - uint256 remainingSupply - ) public withFactory(useFactory) { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - amount = bound(amount, 2, 20); - remainingSupply = bound(remainingSupply, 1, amount - 1); - sale.setGlobalSaleDetails( - perTokenCost, remainingSupply, uint64(block.timestamp), uint64(block.timestamp + 1), "" - ); + uint256 expectedCost = details.cost * amount; + erc20.mint(address(this), expectedCost); + erc20.approve(address(sale), expectedCost); - uint256 cost = amount * perTokenCost; - - vm.expectRevert(abi.encodeWithSelector(InsufficientSupply.selector, remainingSupply, amount)); - sale.mint{ value: cost }( - mintTo, - TestHelper.singleToArray(tokenId), - TestHelper.singleToArray(amount), - "", - address(0), - cost, - TestHelper.blankProof() - ); + vm.expectEmit(true, true, true, true, address(token)); + emit TransferBatch(address(sale), address(0), recipient, tokenIds, amounts); + vm.expectEmit(true, true, true, true, address(sale)); + emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndex); + sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(erc20), expectedCost, TestHelper.blankProof()); + + assertEq(erc20.balanceOf(address(this)), 0); + assertEq(erc20.balanceOf(address(sale)), expectedCost); + + return saleIndex; } - // Minting denied when token supply exceeded. - function testMintTokenSupplyExceeded( + function test_mint_fail_invalidTokenId( bool useFactory, - address mintTo, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, uint256 amount, - uint256 remainingSupply + bool tokenIdAboveMax ) public withFactory(useFactory) { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - amount = bound(amount, 2, 20); - remainingSupply = bound(remainingSupply, 1, amount - 1); - sale.setTokenSaleDetails( - tokenId, perTokenCost, remainingSupply, uint64(block.timestamp), uint64(block.timestamp + 1), "" - ); + details = validSaleDetails(tokenId, details); + if (tokenIdAboveMax) { + vm.assume(details.maxTokenId < type(uint256).max); + tokenId = bound(tokenId, details.maxTokenId + 1, type(uint256).max); + } else { + vm.assume(details.minTokenId > 0); + tokenId = bound(tokenId, 0, details.minTokenId - 1); + } - uint256 cost = amount * perTokenCost; - - vm.expectRevert(abi.encodeWithSelector(InsufficientSupply.selector, remainingSupply, amount)); - sale.mint{ value: cost }( - mintTo, - TestHelper.singleToArray(tokenId), - TestHelper.singleToArray(amount), - "", - address(0), - cost, - TestHelper.blankProof() - ); - } + uint256 saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); + uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); + uint256[] memory amounts = TestHelper.singleToArray(amount); - // Minting allowed when sale is active globally. - function testMintGlobalSuccess( - bool useFactory, - address mintTo, - uint256 tokenId, - uint256 amount - ) public withFactory(useFactory) withGlobalSaleActive { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - uint256 cost = amount * perTokenCost; - - uint256 count = token.balanceOf(mintTo, tokenId); - { - uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); - uint256[] memory amounts = TestHelper.singleToArray(amount); - vm.expectEmit(true, true, true, true, address(token)); - emit TransferBatch(address(sale), address(0), mintTo, tokenIds, amounts); - vm.expectEmit(true, true, true, true, address(sale)); - emit ItemsMinted(mintTo, tokenIds, amounts); - sale.mint{ value: cost }(mintTo, tokenIds, amounts, "", address(0), cost, TestHelper.blankProof()); - } - assertEq(count + amount, token.balanceOf(mintTo, tokenId)); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidSaleDetails.selector)); + sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(0), 0, TestHelper.blankProof()); } - // Minting allowed when sale is active for the token. - function testMintSingleSuccess( + function test_mint_successERC20_higherExpectedCost( bool useFactory, - address mintTo, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, - uint256 amount - ) public withFactory(useFactory) { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - setTokenSaleActive(tokenId); - uint256 cost = amount * perTokenCost; - - uint256 count = token.balanceOf(mintTo, tokenId); - { - uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); - uint256[] memory amounts = TestHelper.singleToArray(amount); - vm.expectEmit(true, true, true, true, address(token)); - emit TransferBatch(address(sale), address(0), mintTo, tokenIds, amounts); - vm.expectEmit(true, true, true, true, address(sale)); - emit ItemsMinted(mintTo, tokenIds, amounts); - sale.mint{ value: cost }(mintTo, tokenIds, amounts, "", address(0), cost, TestHelper.blankProof()); - } - assertEq(count + amount, token.balanceOf(mintTo, tokenId)); - } + uint256 amount, + uint256 expectedCost + ) public withFactory(useFactory) returns (uint256 saleIndex) { + assumeSafeAddress(recipient); + details = validSaleDetails(tokenId, details); + details.paymentToken = address(erc20); + saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); + uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); + uint256[] memory amounts = TestHelper.singleToArray(amount); + + uint256 realExpectedCost = details.cost * amount; + expectedCost = bound(expectedCost, realExpectedCost, type(uint256).max); + erc20.mint(address(this), expectedCost); + erc20.approve(address(sale), expectedCost); - // Minting allowed when sale is active for both tokens individually. - function testMintGroupSuccess( - bool useFactory, - address mintTo, - uint256 tokenId, - uint256 amount - ) public withFactory(useFactory) { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - setTokenSaleActive(tokenId); - setTokenSaleActive(tokenId + 1); - uint256[] memory tokenIds = new uint256[](2); - tokenIds[0] = tokenId; - tokenIds[1] = tokenId + 1; - uint256[] memory amounts = new uint256[](2); - amounts[0] = amount; - amounts[1] = amount; - uint256 cost = amount * perTokenCost * 2; - - uint256 count = token.balanceOf(mintTo, tokenId); - uint256 count2 = token.balanceOf(mintTo, tokenId + 1); vm.expectEmit(true, true, true, true, address(token)); - emit TransferBatch(address(sale), address(0), mintTo, tokenIds, amounts); + emit TransferBatch(address(sale), address(0), recipient, tokenIds, amounts); vm.expectEmit(true, true, true, true, address(sale)); - emit ItemsMinted(mintTo, tokenIds, amounts); - sale.mint{ value: cost }(mintTo, tokenIds, amounts, "", address(0), cost, TestHelper.blankProof()); - assertEq(count + amount, token.balanceOf(mintTo, tokenId)); - assertEq(count2 + amount, token.balanceOf(mintTo, tokenId + 1)); + emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndex); + sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(erc20), expectedCost, TestHelper.blankProof()); + + assertEq(erc20.balanceOf(address(this)), expectedCost - realExpectedCost); + assertEq(erc20.balanceOf(address(sale)), realExpectedCost); + + return saleIndex; } - // Minting allowed when global sale is free. - function testFreeGlobalMint( + function test_mint_fail_beforeSale( bool useFactory, - address mintTo, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, - uint256 amount + uint256 amount, + uint256 blockTime ) public withFactory(useFactory) { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - sale.setGlobalSaleDetails(0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), ""); + details = validSaleDetails(tokenId, details); + blockTime = bound(blockTime, 0, type(uint64).max - 1); + details.startTime = uint64(bound(details.startTime, blockTime + 1, type(uint64).max)); + details.endTime = uint64(bound(details.endTime, details.startTime, type(uint64).max)); + vm.warp(blockTime); + + uint256 saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); - uint256 count = token.balanceOf(mintTo, tokenId); - vm.expectEmit(true, true, true, true, address(token)); - emit TransferBatch(address(sale), address(0), mintTo, tokenIds, amounts); - vm.expectEmit(true, true, true, true, address(sale)); - emit ItemsMinted(mintTo, tokenIds, amounts); - sale.mint(mintTo, tokenIds, amounts, "", address(0), 0, TestHelper.blankProof()); - assertEq(count + amount, token.balanceOf(mintTo, tokenId)); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.SaleInactive.selector)); + sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(0), 0, TestHelper.blankProof()); } - // Minting allowed when token sale is free and global is not. - function testFreeTokenMint( + function test_mint_fail_afterSale( bool useFactory, - address mintTo, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, - uint256 amount - ) public withFactory(useFactory) withGlobalSaleActive { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - sale.setTokenSaleDetails( - tokenId, 0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), "" - ); + uint256 amount, + uint256 blockTime + ) public withFactory(useFactory) { + details = validSaleDetails(tokenId, details); + blockTime = bound(blockTime, 1, type(uint64).max); + details.endTime = uint64(bound(details.endTime, 0, blockTime - 1)); + details.startTime = uint64(bound(details.startTime, 0, details.endTime)); + vm.warp(blockTime); + + uint256 saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); - uint256 count = token.balanceOf(mintTo, tokenId); - vm.expectEmit(true, true, true, true, address(token)); - emit TransferBatch(address(sale), address(0), mintTo, tokenIds, amounts); - vm.expectEmit(true, true, true, true, address(sale)); - emit ItemsMinted(mintTo, tokenIds, amounts); - sale.mint(mintTo, tokenIds, amounts, "", address(0), 0, TestHelper.blankProof()); - assertEq(count + amount, token.balanceOf(mintTo, tokenId)); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.SaleInactive.selector)); + sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(0), 0, TestHelper.blankProof()); } - // Minting allowed when mint charged with ERC20. - function testERC20Mint( + function test_mint_fail_supplyExceeded( bool useFactory, - address mintTo, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, uint256 amount - ) public withFactory(useFactory) withERC20 { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - sale.setPaymentToken(address(erc20)); - sale.setGlobalSaleDetails( - perTokenCost, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), "" - ); + ) public withFactory(useFactory) { + details = validSaleDetails(tokenId, details); + details.supply = bound(details.supply, 1, type(uint256).max - 1); + + uint256 saleIndex = sale.addSaleDetails(details); + amount = bound(amount, details.supply + 1, type(uint256).max); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); - uint256 cost = amount * perTokenCost; - uint256 balance = erc20.balanceOf(address(this)); - uint256 count = token.balanceOf(mintTo, tokenId); - vm.expectEmit(true, true, true, true, address(token)); - emit TransferBatch(address(sale), address(0), mintTo, tokenIds, amounts); - vm.expectEmit(true, true, true, true, address(sale)); - emit ItemsMinted(mintTo, tokenIds, amounts); - sale.mint(mintTo, tokenIds, amounts, "", address(erc20), cost, TestHelper.blankProof()); - assertEq(count + amount, token.balanceOf(mintTo, tokenId)); - assertEq(balance - cost, erc20.balanceOf(address(this))); - assertEq(cost, erc20.balanceOf(address(sale))); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InsufficientSupply.selector, details.supply, amount)); + sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(0), 0, TestHelper.blankProof()); } - // Minting with merkle success. - function testMerkleSuccess( - address[] memory allowlist, - uint256 senderIndex, + function test_mint_fail_supplyExceededOnSubsequentMint( + bool useFactory, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, - bool globalActive - ) public returns (address sender, bytes32 root, bytes32[] memory proof) { - // Construct a merkle tree with the allowlist. - vm.assume(allowlist.length > 1); - senderIndex = bound(senderIndex, 0, allowlist.length - 1); - sender = allowlist[senderIndex]; - assumeSafeAddress(sender); - - uint256 salt = globalActive ? type(uint256).max : tokenId; - (root, proof) = TestHelper.getMerkleParts(allowlist, salt, senderIndex); - - if (globalActive) { - sale.setGlobalSaleDetails( - 0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), root - ); - } else { - sale.setTokenSaleDetails( - tokenId, 0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), root - ); - } + uint256 minted, + uint256 amount + ) public withFactory(useFactory) { + details = validSaleDetails(tokenId, details); + details.supply = bound(details.supply, 1, type(uint256).max - 1); + minted = bound(minted, 1, details.supply); + uint256 saleIndex = test_mint_success(useFactory, recipient, details, tokenId, minted); - vm.expectEmit(true, true, true, true, address(sale)); - uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); - uint256[] memory amounts = TestHelper.singleToArray(uint256(1)); - emit ItemsMinted(sender, tokenIds, amounts); - vm.prank(sender); - sale.mint(sender, tokenIds, amounts, "", address(0), 0, proof); + // New amount exceeds supply + amount = bound(amount, details.supply - minted + 1, type(uint256).max); - assertEq(1, token.balanceOf(sender, tokenId)); + uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); + uint256[] memory amounts = TestHelper.singleToArray(amount); + vm.expectRevert( + abi.encodeWithSelector(IERC1155Sale.InsufficientSupply.selector, details.supply - minted, amount) + ); + sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(0), 0, TestHelper.blankProof()); } - // Minting with merkle success. - function testMerkleSuccessGlobalMultiple( - address[] memory allowlist, - uint256 senderIndex, - uint256[] memory tokenIds - ) public { - uint256 tokenIdLen = tokenIds.length; - vm.assume(tokenIdLen > 1); - vm.assume(tokenIds[0] != tokenIds[1]); - if (tokenIds[0] > tokenIds[1]) { - // Must be ordered - (tokenIds[1], tokenIds[0]) = (tokenIds[0], tokenIds[1]); - } - - // solhint-disable-next-line no-inline-assembly - assembly { - mstore(tokenIds, 2) // Exactly 2 unique tokenIds + function test_mint_fail_incorrectPayment( + bool useFactory, + address recipient, + IERC1155Sale.SaleDetails memory details, + uint256 tokenId, + uint256 amount, + uint256 expectedCost + ) public withFactory(useFactory) { + assumeSafeAddress(recipient); + details = validSaleDetails(tokenId, details); + if (details.cost == 0) { + details.cost = 1; } - uint256[] memory amounts = new uint256[](2); - amounts[0] = 1; - amounts[1] = 1; - - // Construct a merkle tree with the allowlist. - vm.assume(allowlist.length > 1); - senderIndex = bound(senderIndex, 0, allowlist.length - 1); - address sender = allowlist[senderIndex]; - assumeSafeAddress(sender); - - (bytes32 root, bytes32[] memory proof) = TestHelper.getMerkleParts(allowlist, type(uint256).max, senderIndex); + uint256 saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); + uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); + uint256[] memory amounts = TestHelper.singleToArray(amount); - sale.setGlobalSaleDetails(0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), root); + uint256 realExpectedCost = details.cost * amount; + vm.assume(expectedCost != realExpectedCost); // Overpayment should fail too + vm.deal(address(this), expectedCost); - vm.expectEmit(true, true, true, true, address(sale)); - emit ItemsMinted(sender, tokenIds, amounts); - vm.prank(sender); - sale.mint(sender, tokenIds, amounts, "", address(0), 0, proof); - - assertEq(1, token.balanceOf(sender, tokenIds[0])); - assertEq(1, token.balanceOf(sender, tokenIds[1])); + vm.expectRevert( + abi.encodeWithSelector( + IERC1155Sale.InsufficientPayment.selector, details.paymentToken, realExpectedCost, expectedCost + ) + ); + sale.mint{ value: expectedCost }( + recipient, tokenIds, amounts, "", saleIndex, address(0), expectedCost, TestHelper.blankProof() + ); } - // Minting with merkle reuse fail. - function testMerkleReuseFail( - address[] memory allowlist, - uint256 senderIndex, + function test_mint_fail_insufficientPaymentERC20( + bool useFactory, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, - bool globalActive - ) public { - (address sender, bytes32 root, bytes32[] memory proof) = - testMerkleSuccess(allowlist, senderIndex, tokenId, globalActive); - - { - vm.expectRevert( - abi.encodeWithSelector( - MerkleProofInvalid.selector, root, proof, sender, globalActive ? type(uint256).max : tokenId - ) - ); - vm.prank(sender); - sale.mint( - sender, - TestHelper.singleToArray(tokenId), - TestHelper.singleToArray(uint256(1)), - "", - address(0), - 0, - proof - ); + uint256 amount, + uint256 expectedCost + ) public withFactory(useFactory) { + assumeSafeAddress(recipient); + details = validSaleDetails(tokenId, details); + details.paymentToken = address(erc20); + if (details.cost == 0) { + details.cost = 1; } - } + uint256 saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); + uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); + uint256[] memory amounts = TestHelper.singleToArray(amount); - // Minting with merkle fail no proof. - function testMerkleFailNoProof( - address[] memory allowlist, - address sender, - uint256 tokenId, - bool globalActive - ) public { - // Construct a merkle tree with the allowlist. - vm.assume(allowlist.length > 1); - - uint256 salt = globalActive ? type(uint256).max : tokenId; - (bytes32 root,) = TestHelper.getMerkleParts(allowlist, salt, 0); - bytes32[] memory proof = TestHelper.blankProof(); - - if (globalActive) { - sale.setGlobalSaleDetails( - 0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), root - ); - } else { - sale.setTokenSaleDetails( - tokenId, 0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), root - ); - } + uint256 realExpectedCost = details.cost * amount; + erc20.mint(address(this), realExpectedCost); + erc20.approve(address(sale), realExpectedCost); + expectedCost = bound(expectedCost, 0, realExpectedCost - 1); - vm.expectRevert(abi.encodeWithSelector(MerkleProofInvalid.selector, root, proof, sender, salt)); - vm.prank(sender); - sale.mint( - sender, TestHelper.singleToArray(tokenId), TestHelper.singleToArray(uint256(1)), "", address(0), 0, proof + vm.expectRevert( + abi.encodeWithSelector( + IERC1155Sale.InsufficientPayment.selector, details.paymentToken, realExpectedCost, expectedCost + ) ); + sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(erc20), expectedCost, TestHelper.blankProof()); } - // Minting with merkle fail bad proof. - function testMerkleFailBadProof( - address[] memory allowlist, - address sender, + function test_mint_fail_invalidExpectedPaymentToken( + bool useFactory, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, - bool globalActive - ) public { - // Construct a merkle tree with the allowlist. - vm.assume(allowlist.length > 1); - vm.assume(allowlist[1] != sender); - - uint256 salt = globalActive ? type(uint256).max : tokenId; - (bytes32 root, bytes32[] memory proof) = TestHelper.getMerkleParts(allowlist, salt, 1); // Wrong sender - - if (globalActive) { - sale.setGlobalSaleDetails( - 0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), root - ); - } else { - sale.setTokenSaleDetails( - tokenId, 0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), root - ); - } - + uint256 amount, + address expectedPaymentToken + ) public withFactory(useFactory) { + assumeSafeAddress(recipient); + details = validSaleDetails(tokenId, details); + vm.assume(details.paymentToken != expectedPaymentToken); + uint256 saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); - uint256[] memory amounts = TestHelper.singleToArray(uint256(1)); + uint256[] memory amounts = TestHelper.singleToArray(amount); - vm.expectRevert(abi.encodeWithSelector(MerkleProofInvalid.selector, root, proof, sender, salt)); - vm.prank(sender); - sale.mint(sender, tokenIds, amounts, "", address(0), 0, proof); + uint256 expectedCost = details.cost * amount; + vm.deal(address(this), expectedCost); + + vm.expectRevert( + abi.encodeWithSelector(IERC1155Sale.InsufficientPayment.selector, details.paymentToken, expectedCost, 0) + ); + sale.mint{ value: expectedCost }( + recipient, tokenIds, amounts, "", saleIndex, expectedPaymentToken, expectedCost, TestHelper.blankProof() + ); } - // Minting fails with invalid maxTotal. - function testMintFailMaxTotal( + function test_mint_fail_invalidExpectedCost( bool useFactory, - address mintTo, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, - uint256 amount - ) public withFactory(useFactory) withGlobalSaleActive { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); + uint256 amount, + uint256 expectedCost + ) public withFactory(useFactory) { + assumeSafeAddress(recipient); + details = validSaleDetails(tokenId, details); + if (details.cost == 0) { + details.cost = 1; + } + uint256 saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); - uint256 cost = amount * perTokenCost; - bytes memory err = abi.encodeWithSelector(InsufficientPayment.selector, address(0), cost, cost - 1); + uint256 realExpectedCost = details.cost * amount; + vm.deal(address(this), realExpectedCost); + expectedCost = bound(expectedCost, 0, realExpectedCost - 1); - vm.expectRevert(err); - sale.mint{ value: cost }(mintTo, tokenIds, amounts, "", address(0), cost - 1, TestHelper.blankProof()); - - sale.setTokenSaleDetails( - tokenId, perTokenCost, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), "" + vm.expectRevert( + abi.encodeWithSelector( + IERC1155Sale.InsufficientPayment.selector, details.paymentToken, realExpectedCost, expectedCost + ) ); - vm.expectRevert(err); - sale.mint{ value: cost }(mintTo, tokenIds, amounts, "", address(0), cost - 1, TestHelper.blankProof()); - - sale.setPaymentToken(address(erc20)); - sale.setGlobalSaleDetails( - perTokenCost, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), "" + sale.mint{ value: realExpectedCost }( + recipient, tokenIds, amounts, "", saleIndex, details.paymentToken, expectedCost, TestHelper.blankProof() ); - vm.expectRevert(err); - sale.mint(mintTo, tokenIds, amounts, "", address(erc20), cost - 1, TestHelper.blankProof()); } - // Minting fails with invalid payment token. - function testMintFailWrongPaymentToken( + function test_mint_fail_invalidExpectedCostERC20( bool useFactory, - address mintTo, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, uint256 amount, - address wrongToken - ) public withFactory(useFactory) withERC20 { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - address paymentToken = wrongToken == address(0) ? address(erc20) : address(0); - sale.setPaymentToken(paymentToken); - sale.setGlobalSaleDetails(0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), ""); - - bytes memory err = abi.encodeWithSelector(InsufficientPayment.selector, paymentToken, 0, 0); - vm.expectRevert(err); - sale.mint( - mintTo, - TestHelper.singleToArray(tokenId), - TestHelper.singleToArray(amount), - "", - wrongToken, - 0, - TestHelper.blankProof() - ); + uint256 expectedCost + ) public withFactory(useFactory) { + assumeSafeAddress(recipient); + details = validSaleDetails(tokenId, details); + details.paymentToken = address(erc20); + if (details.cost == 0) { + details.cost = 1; + } + uint256 saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); + uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); + uint256[] memory amounts = TestHelper.singleToArray(amount); - sale.setTokenSaleDetails( - tokenId, 0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), "" - ); + uint256 realExpectedCost = details.cost * amount; + erc20.mint(address(this), realExpectedCost); + erc20.approve(address(sale), realExpectedCost); + expectedCost = bound(expectedCost, 0, realExpectedCost - 1); - vm.expectRevert(err); + vm.expectRevert( + abi.encodeWithSelector( + IERC1155Sale.InsufficientPayment.selector, details.paymentToken, realExpectedCost, expectedCost + ) + ); sale.mint( - mintTo, - TestHelper.singleToArray(tokenId), - TestHelper.singleToArray(amount), - "", - wrongToken, - 0, - TestHelper.blankProof() + recipient, tokenIds, amounts, "", saleIndex, details.paymentToken, expectedCost, TestHelper.blankProof() ); } - // Minting fails with invalid payment token. - function testERC20MintFailPaidETH( + function test_mint_fail_valueOnERC20Payment( bool useFactory, - address mintTo, + address recipient, + IERC1155Sale.SaleDetails memory details, uint256 tokenId, - uint256 amount - ) public withFactory(useFactory) withERC20 { - (tokenId, amount) = assumeSafe(mintTo, tokenId, amount); - sale.setPaymentToken(address(erc20)); - sale.setGlobalSaleDetails(0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), ""); - - bytes memory err = abi.encodeWithSelector(InsufficientPayment.selector, address(0), 0, 1); - vm.expectRevert(err); - sale.mint{ value: 1 }( - mintTo, - TestHelper.singleToArray(tokenId), - TestHelper.singleToArray(amount), - "", - address(erc20), - 0, - TestHelper.blankProof() - ); + uint256 amount, + uint256 value + ) public withFactory(useFactory) returns (uint256 saleIndex) { + assumeSafeAddress(recipient); + details = validSaleDetails(tokenId, details); + details.paymentToken = address(erc20); + saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); + uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); + uint256[] memory amounts = TestHelper.singleToArray(amount); - sale.setTokenSaleDetails( - tokenId, 0, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), "" - ); + uint256 expectedCost = details.cost * amount; + erc20.mint(address(this), expectedCost); + erc20.approve(address(sale), expectedCost); + + value = bound(value, 1, type(uint256).max); + vm.deal(address(this), value); - vm.expectRevert(err); - sale.mint{ value: 1 }( - mintTo, - TestHelper.singleToArray(tokenId), - TestHelper.singleToArray(amount), - "", - address(erc20), - 0, - TestHelper.blankProof() + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InsufficientPayment.selector, address(0), 0, value)); + sale.mint{ value: value }( + recipient, tokenIds, amounts, "", saleIndex, address(erc20), expectedCost, TestHelper.blankProof() ); } @@ -692,39 +470,19 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SaleSignals, IERC1155SupplyS _; } - function assumeSafe( - address nonContract, - uint256 tokenId, - uint256 amount - ) private view returns (uint256 boundTokenId, uint256 boundAmount) { - assumeSafeAddress(nonContract); - vm.assume(nonContract != proxyOwner); - tokenId = bound(tokenId, 0, 100); - amount = bound(amount, 1, 19); - return (tokenId, amount); - } - - // Create ERC20. Give this contract 1000 ERC20 tokens. Approve token to spend 100 ERC20 tokens. - modifier withERC20() { - erc20 = new ERC20Mock(address(this)); - erc20.mint(address(this), 1000 ether); - erc20.approve(address(sale), 1000 ether); - _; - } - - modifier withGlobalSaleActive() { - sale.setGlobalSaleDetails( - perTokenCost, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), "" - ); - _; - } - - function setTokenSaleActive( - uint256 tokenId - ) private { - sale.setTokenSaleDetails( - tokenId, perTokenCost, type(uint256).max, uint64(block.timestamp - 1), uint64(block.timestamp + 1), "" - ); + function validSaleDetails( + uint256 validTokenId, + IERC1155Sale.SaleDetails memory saleDetails + ) public view returns (IERC1155Sale.SaleDetails memory) { + saleDetails.minTokenId = bound(saleDetails.minTokenId, 0, validTokenId); + saleDetails.maxTokenId = bound(saleDetails.maxTokenId, validTokenId, type(uint256).max); + saleDetails.supply = bound(saleDetails.supply, 1, type(uint256).max); + saleDetails.cost = bound(saleDetails.cost, 0, type(uint256).max / saleDetails.supply); + saleDetails.startTime = uint64(bound(saleDetails.startTime, 0, block.timestamp)); + saleDetails.endTime = uint64(bound(saleDetails.endTime, block.timestamp, type(uint64).max)); + saleDetails.paymentToken = address(0); + saleDetails.merkleRoot = bytes32(0); + return saleDetails; } } From 39cc36d87e48e2c9cedf14acbcfcbdcd34e16222 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Tue, 1 Jul 2025 12:51:49 +1200 Subject: [PATCH 2/4] Support multiple sales in a single mint --- .../ERC1155/utility/sale/ERC1155Sale.sol | 55 +-- .../ERC1155/utility/sale/IERC1155Sale.sol | 38 ++- .../utility/sale/ERC1155SaleBase.t.sol | 11 +- .../utility/sale/ERC1155SaleMint.t.sol | 323 ++++++++++++++++-- 4 files changed, 356 insertions(+), 71 deletions(-) diff --git a/src/tokens/ERC1155/utility/sale/ERC1155Sale.sol b/src/tokens/ERC1155/utility/sale/ERC1155Sale.sol index 625ab747..e8caeab7 100644 --- a/src/tokens/ERC1155/utility/sale/ERC1155Sale.sol +++ b/src/tokens/ERC1155/utility/sale/ERC1155Sale.sol @@ -52,29 +52,36 @@ contract ERC1155Sale is IERC1155Sale, WithdrawControlled, MerkleProofSingleUse, * Checks the sale is active, valid and takes payment. * @param _tokenIds Token IDs to mint. * @param _amounts Amounts of tokens to mint. - * @param _saleIndex Sale index to mint from. + * @param _saleIndexes Sale indexes for each token. * @param _expectedPaymentToken ERC20 token address to accept payment in. address(0) indicates ETH. * @param _maxTotal Maximum amount of payment tokens. - * @param _proof Merkle proof for allowlist minting. + * @param _proofs Merkle proofs for allowlist minting. */ function _validateMint( uint256[] calldata _tokenIds, uint256[] calldata _amounts, - uint256 _saleIndex, + uint256[] calldata _saleIndexes, address _expectedPaymentToken, uint256 _maxTotal, - bytes32[] calldata _proof + bytes32[][] calldata _proofs ) private { uint256 totalCost; - // Find the sale details for the token - if (_saleIndex >= _saleDetails.length) { - revert SaleDetailsNotFound(_saleIndex); + // Validate input arrays have matching lengths + uint256 length = _tokenIds.length; + if (length != _amounts.length || length != _saleIndexes.length || length != _proofs.length) { + revert InvalidArrayLengths(); } - SaleDetails memory details = _saleDetails[_saleIndex]; - for (uint256 i; i < _tokenIds.length; i++) { + for (uint256 i; i < length; i++) { uint256 tokenId = _tokenIds[i]; + uint256 saleIndex = _saleIndexes[i]; + + // Find the sale details for the token + if (saleIndex >= _saleDetails.length) { + revert SaleDetailsNotFound(saleIndex); + } + SaleDetails memory details = _saleDetails[saleIndex]; // Check if token is within the sale range if (tokenId < details.minTokenId || tokenId > details.maxTokenId) { @@ -87,26 +94,30 @@ contract ERC1155Sale is IERC1155Sale, WithdrawControlled, MerkleProofSingleUse, revert SaleInactive(); } + // Validate payment token matches expected + if (details.paymentToken != _expectedPaymentToken) { + revert PaymentTokenMismatch(); + } + uint256 amount = _amounts[i]; + if (amount == 0) { + revert InvalidAmount(); + } // Check supply - uint256 minted = _tokensMintedPerSale[tokenId][_saleIndex]; + uint256 minted = _tokensMintedPerSale[tokenId][saleIndex]; if (amount > details.supply - minted) { revert InsufficientSupply(details.supply - minted, amount); } // Check merkle proof - requireMerkleProof(details.merkleRoot, _proof, msg.sender, bytes32(tokenId)); + requireMerkleProof(details.merkleRoot, _proofs[i], msg.sender, bytes32(tokenId)); // Update supply and calculate cost - _tokensMintedPerSale[tokenId][_saleIndex] = minted + amount; + _tokensMintedPerSale[tokenId][saleIndex] = minted + amount; totalCost += details.cost * amount; } - if (_expectedPaymentToken != details.paymentToken) { - // Caller expected different payment token - revert InsufficientPayment(details.paymentToken, totalCost, 0); - } if (_maxTotal < totalCost) { // Caller expected to pay less revert InsufficientPayment(_expectedPaymentToken, totalCost, _maxTotal); @@ -132,23 +143,21 @@ contract ERC1155Sale is IERC1155Sale, WithdrawControlled, MerkleProofSingleUse, /// @inheritdoc IERC1155Sale /// @notice Sale must be active for all tokens. + /// @dev All sales must use the same payment token. /// @dev An empty proof is supplied when no proof is required. function mint( address to, uint256[] calldata tokenIds, uint256[] calldata amounts, bytes calldata data, - uint256 saleIndex, + uint256[] calldata saleIndexes, address expectedPaymentToken, uint256 maxTotal, - bytes32[] calldata proof + bytes32[][] calldata proofs ) public payable { - if (tokenIds.length != amounts.length) { - revert InvalidTokenIds(); - } - _validateMint(tokenIds, amounts, saleIndex, expectedPaymentToken, maxTotal, proof); + _validateMint(tokenIds, amounts, saleIndexes, expectedPaymentToken, maxTotal, proofs); IERC1155ItemsFunctions(_items).batchMint(to, tokenIds, amounts, data); - emit ItemsMinted(to, tokenIds, amounts, saleIndex); + emit ItemsMinted(to, tokenIds, amounts, saleIndexes); } // diff --git a/src/tokens/ERC1155/utility/sale/IERC1155Sale.sol b/src/tokens/ERC1155/utility/sale/IERC1155Sale.sol index 919545e9..1df19b18 100644 --- a/src/tokens/ERC1155/utility/sale/IERC1155Sale.sol +++ b/src/tokens/ERC1155/utility/sale/IERC1155Sale.sol @@ -68,15 +68,15 @@ interface IERC1155Sale { /** * Mint tokens. * @param to Address to mint tokens to. - * @param saleIndex Index of the token sale details to mint. * @param tokenIds Token IDs to mint. * @param amounts Amounts of tokens to mint. * @param data Data to pass if receiver is contract. - * @param paymentToken ERC20 token address to accept payment in. address(0) indicates ETH. + * @param saleIndexes Sale indexes for each token. Must match tokenIds length. + * @param expectedPaymentToken ERC20 token address to accept payment in. address(0) indicates ETH. * @param maxTotal Maximum amount of payment tokens. - * @param proof Merkle proof for allowlist minting. + * @param proofs Merkle proofs for allowlist minting. Must match tokenIds length. * @notice Sale must be active for all tokens. - * @dev tokenIds must be sorted ascending without duplicates. + * @dev All sales must use the same payment token. * @dev An empty proof is supplied when no proof is required. */ function mint( @@ -84,10 +84,10 @@ interface IERC1155Sale { uint256[] calldata tokenIds, uint256[] calldata amounts, bytes calldata data, - uint256 saleIndex, - address paymentToken, + uint256[] calldata saleIndexes, + address expectedPaymentToken, uint256 maxTotal, - bytes32[] calldata proof + bytes32[][] calldata proofs ) external payable; /** @@ -109,9 +109,9 @@ interface IERC1155Sale { * @param to Address that minted the tokens. * @param tokenIds Token IDs that were minted. * @param amounts Amounts of tokens that were minted. - * @param saleIndex Index of the sale details that were minted. + * @param saleIndexes Sale indexes that were minted from. */ - event ItemsMinted(address to, uint256[] tokenIds, uint256[] amounts, uint256 saleIndex); + event ItemsMinted(address to, uint256[] tokenIds, uint256[] amounts, uint256[] saleIndexes); /** * Contract already initialized. @@ -141,11 +141,6 @@ interface IERC1155Sale { */ error InsufficientPayment(address currency, uint256 expected, uint256 actual); - /** - * Invalid token IDs. - */ - error InvalidTokenIds(); - /** * Insufficient supply of tokens. * @param remainingSupply Remaining supply. @@ -153,4 +148,19 @@ interface IERC1155Sale { */ error InsufficientSupply(uint256 remainingSupply, uint256 amount); + /** + * Invalid array lengths. + */ + error InvalidArrayLengths(); + + /** + * Invalid amount. + */ + error InvalidAmount(); + + /** + * Payment token mismatch between sales. + */ + error PaymentTokenMismatch(); + } diff --git a/test/tokens/ERC1155/utility/sale/ERC1155SaleBase.t.sol b/test/tokens/ERC1155/utility/sale/ERC1155SaleBase.t.sol index 2af0c3a3..481dd4cb 100644 --- a/test/tokens/ERC1155/utility/sale/ERC1155SaleBase.t.sol +++ b/test/tokens/ERC1155/utility/sale/ERC1155SaleBase.t.sol @@ -67,23 +67,24 @@ contract ERC1155SaleBaseTest is TestHelper, IERC1155SupplySignals { function testSelectorCollision() public pure { checkSelectorCollision(0xa217fddf); // DEFAULT_ADMIN_ROLE() checkSelectorCollision(0x9d043a66); // acceptImplicitRequest(address,(address,bytes4,bytes32,bytes32,bytes,(string,uint64)),(address,uint256,bytes,uint256,bool,bool,uint256)) + checkSelectorCollision(0x436013db); // addSaleDetails((uint256,uint256,uint256,address,uint256,uint64,uint64,bytes32)) checkSelectorCollision(0xbad43661); // checkMerkleProof(bytes32,bytes32[],address,bytes32) checkSelectorCollision(0x248a9ca3); // getRoleAdmin(bytes32) checkSelectorCollision(0x9010d07c); // getRoleMember(bytes32,uint256) checkSelectorCollision(0xca15c873); // getRoleMemberCount(bytes32) - checkSelectorCollision(0x119cd50c); // globalSaleDetails() checkSelectorCollision(0x2f2ff15d); // grantRole(bytes32,address) checkSelectorCollision(0x91d14854); // hasRole(bytes32,address) checkSelectorCollision(0x63acc14d); // initialize(address,address,address,bytes32) - checkSelectorCollision(0x60e606f6); // mint(address,uint256[],uint256[],bytes,address,uint256,bytes32[]) - checkSelectorCollision(0x3013ce29); // paymentToken() + checkSelectorCollision(0xddced6e7); // mint(address,uint256[],uint256[],bytes,uint256[],address,uint256,bytes32[][]) checkSelectorCollision(0x36568abe); // renounceRole(bytes32,address) checkSelectorCollision(0xd547741f); // revokeRole(bytes32,address) - checkSelectorCollision(0x97559600); // setGlobalSaleDetails(uint256,uint256,uint64,uint64,bytes32) + checkSelectorCollision(0x989d6ed1); // saleDetails(uint256) + checkSelectorCollision(0xce6bcda7); // saleDetailsBatch(uint256[]) + checkSelectorCollision(0xfc640a87); // saleDetailsCount() checkSelectorCollision(0xed4c2ac7); // setImplicitModeProjectId(bytes32) checkSelectorCollision(0x0bb310de); // setImplicitModeValidator(address) - checkSelectorCollision(0x6a326ab1); // setPaymentToken(address) checkSelectorCollision(0x01ffc9a7); // supportsInterface(bytes4) + checkSelectorCollision(0x26f63107); // updateSaleDetails(uint256,(uint256,uint256,uint256,address,uint256,uint64,uint64,bytes32)) checkSelectorCollision(0x44004cc1); // withdrawERC20(address,address,uint256) checkSelectorCollision(0x4782f779); // withdrawETH(address,uint256) } diff --git a/test/tokens/ERC1155/utility/sale/ERC1155SaleMint.t.sol b/test/tokens/ERC1155/utility/sale/ERC1155SaleMint.t.sol index 2dfb1d1b..aadace1a 100644 --- a/test/tokens/ERC1155/utility/sale/ERC1155SaleMint.t.sol +++ b/test/tokens/ERC1155/utility/sale/ERC1155SaleMint.t.sol @@ -50,17 +50,31 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS // // Minting // - function test_mint_fail_invalidArrayLength(uint256[] memory tokenIds, uint256[] memory amounts) public { - vm.assume(tokenIds.length != amounts.length); - vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidTokenIds.selector)); - sale.mint(address(0), tokenIds, amounts, "", 0, address(0), 0, TestHelper.blankProof()); + function test_mint_fail_invalidArrayLength(uint256[] memory array1, uint256[] memory array2) public { + vm.assume(array1.length != array2.length); + bytes32[][] memory proofs = new bytes32[][](array1.length); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidArrayLengths.selector)); + sale.mint(address(0), array1, array1, "", array2, address(0), 0, proofs); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidArrayLengths.selector)); + sale.mint(address(0), array1, array2, "", array1, address(0), 0, proofs); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidArrayLengths.selector)); + sale.mint(address(0), array2, array1, "", array1, address(0), 0, proofs); + } + + function test_mint_fail_invalidProofsArrayLength(uint256[] memory array1, bytes32[][] memory proofs) public { + vm.assume(array1.length != proofs.length); + vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidArrayLengths.selector)); + sale.mint(address(0), array1, array1, "", array1, address(0), 0, proofs); } function test_mint_fail_noSale(uint256 tokenId, uint256 amount) public { uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(0); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.SaleDetailsNotFound.selector, 0)); - sale.mint(address(0), tokenIds, amounts, "", 0, address(0), 0, TestHelper.blankProof()); + sale.mint(address(0), tokenIds, amounts, "", saleIndexes, address(0), 0, proofs); } function test_mint_success( @@ -76,6 +90,9 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); uint256 expectedCost = details.cost * amount; vm.deal(address(this), expectedCost); @@ -83,9 +100,9 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS vm.expectEmit(true, true, true, true, address(token)); emit TransferBatch(address(sale), address(0), recipient, tokenIds, amounts); vm.expectEmit(true, true, true, true, address(sale)); - emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndex); + emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndexes); sale.mint{ value: expectedCost }( - recipient, tokenIds, amounts, "", saleIndex, address(0), expectedCost, TestHelper.blankProof() + recipient, tokenIds, amounts, "", saleIndexes, address(0), expectedCost, proofs ); assertEq(address(sale).balance, expectedCost); @@ -107,6 +124,9 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); uint256 expectedCost = details.cost * amount; erc20.mint(address(this), expectedCost); @@ -115,8 +135,8 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS vm.expectEmit(true, true, true, true, address(token)); emit TransferBatch(address(sale), address(0), recipient, tokenIds, amounts); vm.expectEmit(true, true, true, true, address(sale)); - emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndex); - sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(erc20), expectedCost, TestHelper.blankProof()); + emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndexes); + sale.mint(recipient, tokenIds, amounts, "", saleIndexes, address(erc20), expectedCost, proofs); assertEq(erc20.balanceOf(address(this)), 0); assertEq(erc20.balanceOf(address(sale)), expectedCost); @@ -124,6 +144,47 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS return saleIndex; } + function test_mint_success_proof( + bool useFactory, + address recipient, + IERC1155Sale.SaleDetails memory details, + uint256 tokenId, + uint256 amount, + address[] memory allowlist, + uint256 leafIndex + ) public withFactory(useFactory) { + assumeSafeAddress(recipient); + details = validSaleDetails(tokenId, details); + + vm.assume(allowlist.length > 1); + leafIndex = bound(leafIndex, 0, allowlist.length - 1); + + address sender = allowlist[leafIndex]; + + bytes32[][] memory proofs = new bytes32[][](1); + (details.merkleRoot, proofs[0]) = TestHelper.getMerkleParts(allowlist, tokenId, leafIndex); + + uint256 saleIndex = sale.addSaleDetails(details); + amount = bound(amount, 1, details.supply); + uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); + uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + + uint256 expectedCost = details.cost * amount; + vm.deal(sender, expectedCost); + + vm.expectEmit(true, true, true, true, address(token)); + emit TransferBatch(address(sale), address(0), recipient, tokenIds, amounts); + vm.expectEmit(true, true, true, true, address(sale)); + emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndexes); + vm.prank(sender); + sale.mint{ value: expectedCost }( + recipient, tokenIds, amounts, "", saleIndexes, address(0), expectedCost, proofs + ); + + assertEq(address(sale).balance, expectedCost); + } + function test_mint_fail_invalidTokenId( bool useFactory, address recipient, @@ -145,9 +206,17 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); + + uint256 expectedCost = details.cost * amount; + vm.deal(address(this), expectedCost); vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InvalidSaleDetails.selector)); - sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(0), 0, TestHelper.blankProof()); + sale.mint{ value: expectedCost }( + recipient, tokenIds, amounts, "", saleIndexes, address(0), expectedCost, proofs + ); } function test_mint_successERC20_higherExpectedCost( @@ -165,6 +234,9 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); uint256 realExpectedCost = details.cost * amount; expectedCost = bound(expectedCost, realExpectedCost, type(uint256).max); @@ -174,8 +246,8 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS vm.expectEmit(true, true, true, true, address(token)); emit TransferBatch(address(sale), address(0), recipient, tokenIds, amounts); vm.expectEmit(true, true, true, true, address(sale)); - emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndex); - sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(erc20), expectedCost, TestHelper.blankProof()); + emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndexes); + sale.mint(recipient, tokenIds, amounts, "", saleIndexes, address(erc20), expectedCost, proofs); assertEq(erc20.balanceOf(address(this)), expectedCost - realExpectedCost); assertEq(erc20.balanceOf(address(sale)), realExpectedCost); @@ -201,9 +273,12 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.SaleInactive.selector)); - sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(0), 0, TestHelper.blankProof()); + sale.mint(recipient, tokenIds, amounts, "", saleIndexes, address(0), 0, proofs); } function test_mint_fail_afterSale( @@ -224,9 +299,12 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.SaleInactive.selector)); - sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(0), 0, TestHelper.blankProof()); + sale.mint(recipient, tokenIds, amounts, "", saleIndexes, address(0), 0, proofs); } function test_mint_fail_supplyExceeded( @@ -243,9 +321,12 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, details.supply + 1, type(uint256).max); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InsufficientSupply.selector, details.supply, amount)); - sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(0), 0, TestHelper.blankProof()); + sale.mint(recipient, tokenIds, amounts, "", saleIndexes, address(0), 0, proofs); } function test_mint_fail_supplyExceededOnSubsequentMint( @@ -266,10 +347,13 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); vm.expectRevert( abi.encodeWithSelector(IERC1155Sale.InsufficientSupply.selector, details.supply - minted, amount) ); - sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(0), 0, TestHelper.blankProof()); + sale.mint(recipient, tokenIds, amounts, "", saleIndexes, address(0), 0, proofs); } function test_mint_fail_incorrectPayment( @@ -289,9 +373,12 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); uint256 realExpectedCost = details.cost * amount; - vm.assume(expectedCost != realExpectedCost); // Overpayment should fail too + expectedCost = bound(expectedCost, 0, realExpectedCost - 1); // Force different cost vm.deal(address(this), expectedCost); vm.expectRevert( @@ -300,7 +387,7 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS ) ); sale.mint{ value: expectedCost }( - recipient, tokenIds, amounts, "", saleIndex, address(0), expectedCost, TestHelper.blankProof() + recipient, tokenIds, amounts, "", saleIndexes, address(0), expectedCost, proofs ); } @@ -322,10 +409,13 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); uint256 realExpectedCost = details.cost * amount; erc20.mint(address(this), realExpectedCost); - erc20.approve(address(sale), realExpectedCost); + erc20.approve(address(sale), expectedCost); expectedCost = bound(expectedCost, 0, realExpectedCost - 1); vm.expectRevert( @@ -333,7 +423,7 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS IERC1155Sale.InsufficientPayment.selector, details.paymentToken, realExpectedCost, expectedCost ) ); - sale.mint(recipient, tokenIds, amounts, "", saleIndex, address(erc20), expectedCost, TestHelper.blankProof()); + sale.mint(recipient, tokenIds, amounts, "", saleIndexes, address(erc20), expectedCost, proofs); } function test_mint_fail_invalidExpectedPaymentToken( @@ -351,15 +441,20 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); uint256 expectedCost = details.cost * amount; vm.deal(address(this), expectedCost); vm.expectRevert( - abi.encodeWithSelector(IERC1155Sale.InsufficientPayment.selector, details.paymentToken, expectedCost, 0) + abi.encodeWithSelector( + IERC1155Sale.PaymentTokenMismatch.selector, details.paymentToken, expectedPaymentToken + ) ); sale.mint{ value: expectedCost }( - recipient, tokenIds, amounts, "", saleIndex, expectedPaymentToken, expectedCost, TestHelper.blankProof() + recipient, tokenIds, amounts, "", saleIndexes, expectedPaymentToken, expectedCost, proofs ); } @@ -380,6 +475,9 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); uint256 realExpectedCost = details.cost * amount; vm.deal(address(this), realExpectedCost); @@ -391,7 +489,7 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS ) ); sale.mint{ value: realExpectedCost }( - recipient, tokenIds, amounts, "", saleIndex, details.paymentToken, expectedCost, TestHelper.blankProof() + recipient, tokenIds, amounts, "", saleIndexes, details.paymentToken, expectedCost, proofs ); } @@ -413,10 +511,13 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); uint256 realExpectedCost = details.cost * amount; erc20.mint(address(this), realExpectedCost); - erc20.approve(address(sale), realExpectedCost); + erc20.approve(address(sale), expectedCost); expectedCost = bound(expectedCost, 0, realExpectedCost - 1); vm.expectRevert( @@ -424,9 +525,7 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS IERC1155Sale.InsufficientPayment.selector, details.paymentToken, realExpectedCost, expectedCost ) ); - sale.mint( - recipient, tokenIds, amounts, "", saleIndex, details.paymentToken, expectedCost, TestHelper.blankProof() - ); + sale.mint(recipient, tokenIds, amounts, "", saleIndexes, details.paymentToken, expectedCost, proofs); } function test_mint_fail_valueOnERC20Payment( @@ -444,6 +543,9 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS amount = bound(amount, 1, details.supply); uint256[] memory tokenIds = TestHelper.singleToArray(tokenId); uint256[] memory amounts = TestHelper.singleToArray(amount); + uint256[] memory saleIndexes = TestHelper.singleToArray(saleIndex); + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = TestHelper.blankProof(); uint256 expectedCost = details.cost * amount; erc20.mint(address(this), expectedCost); @@ -453,9 +555,172 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS vm.deal(address(this), value); vm.expectRevert(abi.encodeWithSelector(IERC1155Sale.InsufficientPayment.selector, address(0), 0, value)); - sale.mint{ value: value }( - recipient, tokenIds, amounts, "", saleIndex, address(erc20), expectedCost, TestHelper.blankProof() + sale.mint{ value: value }(recipient, tokenIds, amounts, "", saleIndexes, address(erc20), expectedCost, proofs); + } + + function test_mint_multiple_success( + bool useFactory, + address recipient, + IERC1155Sale.SaleDetails memory details1, + IERC1155Sale.SaleDetails memory details2, + uint256 tokenId1, + uint256 tokenId2, + uint256 amount1, + uint256 amount2 + ) public withFactory(useFactory) { + assumeSafeAddress(recipient); + details1 = validSaleDetails(tokenId1, details1); + details2 = validSaleDetails(tokenId2, details2); + + // Avoid overflows on total cost + details1.cost = details1.cost / 2 + 1; + details2.cost = details2.cost / 2 + 1; + details1.supply = details1.supply / 2 + 1; + details2.supply = details2.supply / 2 + 1; + + amount1 = bound(amount1, 1, details1.supply); + amount2 = bound(amount2, 1, details2.supply); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = tokenId1; + tokenIds[1] = tokenId2; + uint256[] memory amounts = new uint256[](2); + amounts[0] = amount1; + amounts[1] = amount2; + bytes32[][] memory proofs = new bytes32[][](2); + uint256[] memory saleIndexes = new uint256[](2); + + saleIndexes[0] = sale.addSaleDetails(details1); + saleIndexes[1] = sale.addSaleDetails(details2); + + uint256 totalCost = (details1.cost * amount1) + (details2.cost * amount2); + vm.deal(address(this), totalCost); + + vm.expectEmit(true, true, true, true, address(token)); + emit TransferBatch(address(sale), address(0), recipient, tokenIds, amounts); + vm.expectEmit(true, true, true, true, address(sale)); + emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndexes); + sale.mint{ value: totalCost }( + recipient, tokenIds, amounts, "", saleIndexes, details1.paymentToken, totalCost, proofs + ); + + assertEq(address(sale).balance, totalCost); + } + + function test_mint_multiple_success_proof( + bool useFactory, + address recipient, + IERC1155Sale.SaleDetails memory details1, + IERC1155Sale.SaleDetails memory details2, + uint256 tokenId1, + uint256 tokenId2, + uint256 amount1, + uint256 amount2, + address[] memory allowlist1, + uint256 leafIndex1, + address[] memory allowlist2, + uint256 leafIndex2 + ) public withFactory(useFactory) { + assumeSafeAddress(recipient); + details1 = validSaleDetails(tokenId1, details1); + details2 = validSaleDetails(tokenId2, details2); + + // Avoid overflows on total cost + details1.cost = details1.cost / 2 + 1; + details2.cost = details2.cost / 2 + 1; + details1.supply = details1.supply / 2 + 1; + details2.supply = details2.supply / 2 + 1; + + vm.assume(allowlist1.length > 1); + leafIndex1 = bound(leafIndex1, 0, allowlist1.length - 1); + + vm.assume(allowlist2.length > 1); + leafIndex2 = bound(leafIndex2, 0, allowlist2.length - 1); + + address sender = allowlist1[leafIndex1]; + allowlist2[leafIndex2] = sender; + + bytes32[][] memory proofs = new bytes32[][](2); + (details1.merkleRoot, proofs[0]) = TestHelper.getMerkleParts(allowlist1, tokenId1, leafIndex1); + (details2.merkleRoot, proofs[1]) = TestHelper.getMerkleParts(allowlist2, tokenId2, leafIndex2); + + amount1 = bound(amount1, 1, details1.supply); + amount2 = bound(amount2, 1, details2.supply); + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = tokenId1; + tokenIds[1] = tokenId2; + uint256[] memory amounts = new uint256[](2); + amounts[0] = amount1; + amounts[1] = amount2; + uint256[] memory saleIndexes = new uint256[](2); + saleIndexes[0] = sale.addSaleDetails(details1); + saleIndexes[1] = sale.addSaleDetails(details2); + + uint256 expectedCost = details1.cost * amount1 + details2.cost * amount2; + vm.deal(sender, expectedCost); + + vm.expectEmit(true, true, true, true, address(token)); + emit TransferBatch(address(sale), address(0), recipient, tokenIds, amounts); + vm.expectEmit(true, true, true, true, address(sale)); + emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndexes); + vm.prank(sender); + sale.mint{ value: expectedCost }( + recipient, tokenIds, amounts, "", saleIndexes, address(0), expectedCost, proofs ); + + assertEq(address(sale).balance, expectedCost); + } + + function test_mint_multiple_success_ERC20( + bool useFactory, + address recipient, + IERC1155Sale.SaleDetails memory details1, + IERC1155Sale.SaleDetails memory details2, + uint256 tokenId1, + uint256 tokenId2, + uint256 amount1, + uint256 amount2 + ) public withFactory(useFactory) { + assumeSafeAddress(recipient); + details1 = validSaleDetails(tokenId1, details1); + details2 = validSaleDetails(tokenId2, details2); + + details1.paymentToken = address(erc20); + details2.paymentToken = address(erc20); + + // Avoid overflows on total cost + details1.cost = details1.cost / 2 + 1; + details2.cost = details2.cost / 2 + 1; + details1.supply = details1.supply / 2 + 1; + details2.supply = details2.supply / 2 + 1; + + amount1 = bound(amount1, 1, details1.supply); + amount2 = bound(amount2, 1, details2.supply); + + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = tokenId1; + tokenIds[1] = tokenId2; + uint256[] memory amounts = new uint256[](2); + amounts[0] = amount1; + amounts[1] = amount2; + bytes32[][] memory proofs = new bytes32[][](2); + uint256[] memory saleIndexes = new uint256[](2); + + saleIndexes[0] = sale.addSaleDetails(details1); + saleIndexes[1] = sale.addSaleDetails(details2); + + uint256 totalCost = (details1.cost * amount1) + (details2.cost * amount2); + erc20.mint(address(this), totalCost); + erc20.approve(address(sale), totalCost); + + vm.expectEmit(true, true, true, true, address(token)); + emit TransferBatch(address(sale), address(0), recipient, tokenIds, amounts); + vm.expectEmit(true, true, true, true, address(sale)); + emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndexes); + sale.mint(recipient, tokenIds, amounts, "", saleIndexes, details1.paymentToken, totalCost, proofs); + + assertEq(erc20.balanceOf(address(this)), 0); + assertEq(erc20.balanceOf(address(sale)), totalCost); } // From 673169e88d54db43802e9028eb020799b0725a81 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Tue, 1 Jul 2025 13:17:47 +1200 Subject: [PATCH 3/4] Merkle proof restrict use of leaf per root --- src/tokens/common/MerkleProofSingleUse.sol | 21 ++++-- .../utility/sale/ERC1155SaleMint.t.sol | 75 +++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/tokens/common/MerkleProofSingleUse.sol b/src/tokens/common/MerkleProofSingleUse.sol index 323b10fd..4aeaa4c8 100644 --- a/src/tokens/common/MerkleProofSingleUse.sol +++ b/src/tokens/common/MerkleProofSingleUse.sol @@ -6,12 +6,12 @@ import { IMerkleProofSingleUse } from "./IMerkleProofSingleUse.sol"; import { MerkleProof } from "openzeppelin-contracts/contracts/utils/cryptography/MerkleProof.sol"; /** - * Require single use merkle proofs per address. + * Require single use merkle proofs per root. */ abstract contract MerkleProofSingleUse is IMerkleProofSingleUse { - // Stores proofs used by an address - mapping(address => mapping(bytes32 => bool)) private _proofUsed; + // Stores proofs used per root + mapping(bytes32 => mapping(bytes32 => bool)) private _proofUsed; /** * Requires the given merkle proof to be valid. @@ -24,10 +24,11 @@ abstract contract MerkleProofSingleUse is IMerkleProofSingleUse { */ function requireMerkleProof(bytes32 root, bytes32[] calldata proof, address addr, bytes32 salt) internal { if (root != bytes32(0)) { - if (!checkMerkleProof(root, proof, addr, salt)) { + bytes32 leaf = _getLeaf(addr, salt); + if (!_checkMerkleProof(root, proof, leaf)) { revert MerkleProofInvalid(root, proof, addr, salt); } - _proofUsed[addr][root] = true; + _proofUsed[root][leaf] = true; } } @@ -45,7 +46,15 @@ abstract contract MerkleProofSingleUse is IMerkleProofSingleUse { address addr, bytes32 salt ) public view returns (bool) { - return !_proofUsed[addr][root] && MerkleProof.verify(proof, root, keccak256(abi.encodePacked(addr, salt))); + return _checkMerkleProof(root, proof, _getLeaf(addr, salt)); + } + + function _getLeaf(address addr, bytes32 salt) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(addr, salt)); + } + + function _checkMerkleProof(bytes32 root, bytes32[] calldata proof, bytes32 leaf) internal view returns (bool) { + return !_proofUsed[root][leaf] && MerkleProof.verify(proof, root, leaf); } } diff --git a/test/tokens/ERC1155/utility/sale/ERC1155SaleMint.t.sol b/test/tokens/ERC1155/utility/sale/ERC1155SaleMint.t.sol index aadace1a..8523bb2a 100644 --- a/test/tokens/ERC1155/utility/sale/ERC1155SaleMint.t.sol +++ b/test/tokens/ERC1155/utility/sale/ERC1155SaleMint.t.sol @@ -671,6 +671,81 @@ contract ERC1155SaleMintTest is TestHelper, IERC1155SupplySignals, IMerkleProofS assertEq(address(sale).balance, expectedCost); } + function test_mint_repeat_success_proof( + bool useFactory, + address recipient, + IERC1155Sale.SaleDetails memory details, + uint256 tokenId1, + uint256 tokenId2, + uint256 amount1, + uint256 amount2, + address[] memory allowlist, + uint256 leafIndex1, + uint256 leafIndex2 + ) public withFactory(useFactory) { + assumeSafeAddress(recipient); + vm.assume(tokenId1 != tokenId2); + if (tokenId1 > tokenId2) { + (tokenId1, tokenId2) = (tokenId2, tokenId1); + } + details = validSaleDetails(tokenId1, details); + details.maxTokenId = bound(details.maxTokenId, tokenId2, type(uint256).max); + + // Avoid overflows on total cost + details.cost = details.cost / 10 + 1; + details.supply = details.supply / 10 + 1; + + vm.assume(allowlist.length > 1); + uint256 maxAllowList = allowlist.length > 10 ? 10 : allowlist.length; + assembly { + mstore(allowlist, maxAllowList) + } + + // Construct a merkle tree that supports multiple tokens + bytes32[] memory leaves = new bytes32[](allowlist.length * 2); + leafIndex1 = bound(leafIndex1, 0, leaves.length - 1); + leafIndex2 = bound(leafIndex2, 0, leaves.length - 1); + vm.assume(leafIndex1 != leafIndex2); + for (uint256 i = 0; i < leaves.length; i++) { + if (i == leafIndex1) { + leaves[i] = keccak256(abi.encodePacked(address(this), bytes32(tokenId1))); + } else if (i == leafIndex2) { + leaves[i] = keccak256(abi.encodePacked(address(this), bytes32(tokenId2))); + } else { + leaves[i] = keccak256(abi.encodePacked(allowlist[i / 2], bytes32(i % 2 == 0 ? tokenId1 : tokenId2))); + } + } + details.merkleRoot = getRoot(leaves); + bytes32[][] memory proofs = new bytes32[][](2); + proofs[0] = getProof(leaves, leafIndex1); + proofs[1] = getProof(leaves, leafIndex2); + + amount1 = bound(amount1, 1, details.supply); + amount2 = bound(amount2, 1, details.supply); + uint256[] memory tokenIds = new uint256[](2); + tokenIds[0] = tokenId1; + tokenIds[1] = tokenId2; + uint256[] memory amounts = new uint256[](2); + amounts[0] = amount1; + amounts[1] = amount2; + uint256[] memory saleIndexes = new uint256[](2); + saleIndexes[0] = sale.addSaleDetails(details); + saleIndexes[1] = saleIndexes[0]; + + uint256 expectedCost = details.cost * (amount1 + amount2); + vm.deal(address(this), expectedCost); + + vm.expectEmit(true, true, true, true, address(token)); + emit TransferBatch(address(sale), address(0), recipient, tokenIds, amounts); + vm.expectEmit(true, true, true, true, address(sale)); + emit IERC1155Sale.ItemsMinted(recipient, tokenIds, amounts, saleIndexes); + sale.mint{ value: expectedCost }( + recipient, tokenIds, amounts, "", saleIndexes, address(0), expectedCost, proofs + ); + + assertEq(address(sale).balance, expectedCost); + } + function test_mint_multiple_success_ERC20( bool useFactory, address recipient, From 8045b5ec578e5be0da7a0df3dc926aaf9e5c0a5d Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Tue, 1 Jul 2025 13:22:37 +1200 Subject: [PATCH 4/4] Update ERC1155 README --- src/tokens/ERC1155/README.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/tokens/ERC1155/README.md b/src/tokens/ERC1155/README.md index bacc4257..36b13d8a 100644 --- a/src/tokens/ERC1155/README.md +++ b/src/tokens/ERC1155/README.md @@ -18,16 +18,29 @@ This folder contains contracts that are pre-configured for specific use cases. The `ERC1155Items` contract is a preset that configures the `ERC1155BaseToken` contract to allow minting of tokens. It adds a `MINTER_ROLE` and a `mint(address to, uint256 amount)` function that can only be called by accounts with the `MINTER_ROLE`. +## Utilities + +This folder contains contracts that work in conjunction with other ERC1155 contracts. + ### Sale -The `ERC1155Sale` contract is a preset that configures the `ERC1155BaseToken` contract to allow for the sale of tokens. It adds a `mint(address to, , uint256[] memory tokenIds, uint256[] memory amounts, bytes memory data, bytes32[] calldata proof)` function allows for the minting of tokens under various conditions. +The `ERC1155Sale` contract is a utility contract that provides sale functionality for ERC-1155 tokens. It works in conjunction with an `ERC1155Items` contract to handle the minting and sale of tokens under various conditions. + +The contract supports multiple sale configurations through a sale details system. Each sale configuration includes: + +- Token ID range (minTokenId to maxTokenId) +- Cost per token +- Payment token (ETH or ERC20) +- Supply limit per token ID +- Sale time window (startTime to endTime) +- Optional merkle root for allowlist minting -Conditions may be set by the contract owner. Set the payment token with `setPaymentToken(address paymentTokenAddr)`, then use either the `setTokenSaleDetails(uint256 tokenId, uint256 cost, uint256 remainingSupply, uint64 startTime, uint64 endTime, bytes32 merkleRoot)` function for single token settings or the `setGlobalSaleDetails(uint256 cost, uint256 remainingSupply, uint64 startTime, uint64 endTime, bytes32 merkleRoot)` function for global settings. These functions can only be called by accounts with the `MINT_ADMIN_ROLE`. +Conditions may be set by the contract owner using the `addSaleDetails(SaleDetails calldata details)` function for new configurations or `updateSaleDetails(uint256 saleIndex, SaleDetails calldata details)` for existing ones. These functions can only be called by accounts with the `MINT_ADMIN_ROLE`. When using a merkle proof, each caller may only use each root once. To prevent collisions ensure the same root is not used for multiple sale details. -Leaves are defined as `keccak256(abi.encodePacked(caller, tokenId))`. The `caller` is the message sender, who will also receive the tokens. The `tokenId` is the id of the token that will be minted, (for global sales `type(uint256).max` is used). +Leaves are defined as `keccak256(abi.encodePacked(caller, tokenId))`. The `caller` is the message sender, who will also receive the tokens. The `tokenId` is the id of the token that will be minted. -For information about the function parameters, please refer to the function specification in `presets/sale/IERC1155Sale.sol`. +For information about the function parameters, please refer to the function specification in `utility/sale/IERC1155Sale.sol`. ## Usage