diff --git a/contracts/common/interfaces/IMagicDropMetadata.sol b/contracts/common/interfaces/IMagicDropMetadata.sol new file mode 100644 index 00000000..5b5ba913 --- /dev/null +++ b/contracts/common/interfaces/IMagicDropMetadata.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +interface IMagicDropMetadata { + /*============================================================== + = EVENTS = + ==============================================================*/ + + /// @notice Emitted when the contract URI is updated. + /// @param _contractURI The new contract URI. + event ContractURIUpdated(string _contractURI); + + /// @notice Emitted when the royalty info is updated. + /// @param receiver The new royalty receiver. + /// @param bps The new royalty basis points. + event RoyaltyInfoUpdated(address receiver, uint256 bps); + + /// @notice Emitted when the metadata is updated. (EIP-4906) + /// @param _fromTokenId The starting token ID. + /// @param _toTokenId The ending token ID. + event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); + + /// @notice Emitted once when the token contract is deployed and initialized. + event MagicDropTokenDeployed(); + + /*============================================================== + = ERRORS = + ==============================================================*/ + + /// @notice Throw when the max supply is exceeded. + error CannotExceedMaxSupply(); + + /// @notice Throw when the max supply is less than the current supply. + error MaxSupplyCannotBeLessThanCurrentSupply(); + + /// @notice Throw when trying to increase the max supply. + error MaxSupplyCannotBeIncreased(); + + /// @notice Throw when the max supply is greater than 2^64. + error MaxSupplyCannotBeGreaterThan2ToThe64thPower(); + + /*============================================================== + = PUBLIC VIEW METHODS = + ==============================================================*/ + + /// @notice Returns the base URI used to construct token URIs + /// @dev This is concatenated with the token ID to form the complete token URI + /// @return The base URI string that prefixes all token URIs + function baseURI() external view returns (string memory); + + /// @notice Returns the contract-level metadata URI + /// @dev Used by marketplaces like MagicEden to display collection information + /// @return The URI string pointing to the contract's metadata JSON + function contractURI() external view returns (string memory); + + /// @notice Returns the address that receives royalty payments + /// @dev Used in conjunction with royaltyBps for EIP-2981 royalty standard + /// @return The address designated to receive royalty payments + function royaltyAddress() external view returns (address); + + /// @notice Returns the royalty percentage in basis points (1/100th of a percent) + /// @dev 100 basis points = 1%. Used in EIP-2981 royalty calculations + /// @return The royalty percentage in basis points (e.g., 250 = 2.5%) + function royaltyBps() external view returns (uint256); + + /*============================================================== + = ADMIN OPERATIONS = + ==============================================================*/ + + /// @notice Sets the base URI for all token metadata + /// @dev This is a critical function that determines where all token metadata is hosted + /// Changing this will update the metadata location for all tokens in the collection + /// @param baseURI The new base URI string that will prefix all token URIs + function setBaseURI(string calldata baseURI) external; + + /// @notice Sets the contract-level metadata URI + /// @dev This metadata is used by marketplaces to display collection information + /// Should point to a JSON file following collection metadata standards + /// @param contractURI The new URI string pointing to the contract's metadata JSON + function setContractURI(string calldata contractURI) external; + + /// @notice Updates the royalty configuration for the collection + /// @dev Implements EIP-2981 for NFT royalty standards + /// The bps (basis points) must be between 0 and 10000 (0% to 100%) + /// @param newReceiver The address that will receive future royalty payments + /// @param newBps The royalty percentage in basis points (e.g., 250 = 2.5%) + function setRoyaltyInfo(address newReceiver, uint96 newBps) external; +} diff --git a/contracts/nft/erc1155m/clones/ERC1155ConduitPreapprovedCloneable.sol b/contracts/nft/erc1155m/clones/ERC1155ConduitPreapprovedCloneable.sol new file mode 100644 index 00000000..514fea0a --- /dev/null +++ b/contracts/nft/erc1155m/clones/ERC1155ConduitPreapprovedCloneable.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {ERC1155} from "solady/src/tokens/ERC1155.sol"; + +/// @title ERC1155ConduitPreapprovedCloneable +/// @notice ERC1155 token with the MagicEden conduit preapproved for seamless transactions. +abstract contract ERC1155ConduitPreapprovedCloneable is ERC1155 { + /// @dev The canonical MagicEden conduit address. + address internal constant _CONDUIT = 0x2052f8A2Ff46283B30084e5d84c89A2fdBE7f74b; + + /// @notice Safely transfers `amount` tokens of type `id` from `from` to `to`. + /// @param from The address holding the tokens. + /// @param to The address to transfer the tokens to. + /// @param id The token type identifier. + /// @param amount The number of tokens to transfer. + /// @param data Additional data with no specified format. + function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes calldata data) + public + virtual + override + { + _safeTransfer(_by(), from, to, id, amount, data); + } + + /// @notice Safely transfers a batch of tokens from `from` to `to`. + /// @param from The address holding the tokens. + /// @param to The address to transfer the tokens to. + /// @param ids An array of token type identifiers. + /// @param amounts An array of amounts to transfer for each token type. + /// @param data Additional data with no specified format. + function safeBatchTransferFrom( + address from, + address to, + uint256[] calldata ids, + uint256[] calldata amounts, + bytes calldata data + ) public virtual override { + _safeBatchTransfer(_by(), from, to, ids, amounts, data); + } + + /// @notice Checks if `operator` is approved to manage all of `owner`'s tokens. + /// @param owner The address owning the tokens. + /// @param operator The address to query for approval. + /// @return True if `operator` is approved, otherwise false. + function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) { + if (operator == _CONDUIT) return true; + return ERC1155.isApprovedForAll(owner, operator); + } + + /// @dev Determines the address initiating the transfer. + /// If the caller is the predefined conduit, returns address(0), else returns the caller's address. + /// @return result The address initiating the transfer. + function _by() internal view virtual returns (address result) { + assembly { + // `msg.sender == _CONDUIT ? address(0) : msg.sender`. + result := mul(iszero(eq(caller(), _CONDUIT)), caller()) + } + } +} diff --git a/contracts/nft/erc1155m/clones/ERC1155MagicDropCloneable.sol b/contracts/nft/erc1155m/clones/ERC1155MagicDropCloneable.sol new file mode 100644 index 00000000..8a552166 --- /dev/null +++ b/contracts/nft/erc1155m/clones/ERC1155MagicDropCloneable.sol @@ -0,0 +1,492 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {MerkleProofLib} from "solady/src/utils/MerkleProofLib.sol"; +import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; + +import {ERC1155MagicDropMetadataCloneable} from "./ERC1155MagicDropMetadataCloneable.sol"; +import {PublicStage, AllowlistStage, SetupConfig} from "./Types.sol"; +import {IERC1155MagicDropMetadata} from "../interfaces/IERC1155MagicDropMetadata.sol"; + +/// ........ +/// ..... .. ... +/// .. ..... .. .. +/// .. ... ..... .. .. +/// .. ...... .. ...... .. +/// .. ......... ......... .... +/// .... .. .. ... +/// ........ ......... .. +/// .. ... ... .. ......... +/// .. .......... .... .... ....... ........ +/// ....... .. .. ... .... ..... .. +/// ........ . ... .. .. +/// . ..... ........ .... .. +/// .. .. ... ........... ... ... +/// ....... .. ...... ... .. +/// ............ ... ........ .. .. +/// ... ..... .. .. .. .. .. ...... +/// .. ........ ... .. .. .. .... .... +/// ....... .. .. ...... ....... .. +/// .. ..... .. .... .. +/// .. .... ......... . .. .. +/// ... .... .. ......... . .. .. +/// .... .... .. ..... ...... ... +/// ..... .. ........ ... ... +/// ... .. .. .. ...... ..... .. +/// .. .... ... ... .. .. +/// .. .... .. .. .. +/// . ...... .. .. .. +/// .. ...................... .............. +/// .. ................ .... ... +/// . ... ........ +/// .. ... ...... .. +/// .. .... ...EMMY.... +/// .. .. ... .... .... .. +/// .. .. ..... .......... +/// ... .. ... ...... +/// ... .... .. .. +/// .. ..... ... +/// ..... .... ........ ... +/// ........ .. ..... .......... +/// .. ........ .. ..MAGIC..... . +/// .... .... .... ..EDEN.... +/// ..... . ... ...... +/// .. ....... .. +/// ..... ..... +/// .... +/// @title ERC1155MagicDropCloneable +/// @notice A cloneable ERC-1155 drop contract that supports both a public minting stage and an allowlist minting stage. +/// @dev This contract extends metadata configuration, ownership, and royalty support from its parent, while adding +/// time-gated, price-defined minting stages. It also incorporates a payout recipient and protocol fee structure. +contract ERC1155MagicDropCloneable is ERC1155MagicDropMetadataCloneable { + /*============================================================== + = STORAGE = + ==============================================================*/ + + /// @dev Address that receives the primary sale proceeds of minted tokens. + /// Configurable by the owner. If unset, withdrawals may fail. + address internal _payoutRecipient; + + /// @dev The address that receives protocol fees on withdrawal. + /// @notice This is fixed and cannot be changed. + address public constant PROTOCOL_FEE_RECIPIENT = 0xA3833016a4eC61f5c253D71c77522cC8A1cC1106; + + /// @dev The protocol fee expressed in basis points (e.g., 500 = 5%). + /// @notice This fee is taken from the contract's entire balance upon withdrawal. + uint256 public constant PROTOCOL_FEE_BPS = 0; // 0% + + /// @dev The denominator used for calculating basis points. + /// @notice 10,000 BPS = 100%. A fee of 500 BPS is therefore 5%. + uint256 public constant BPS_DENOMINATOR = 10_000; + + /// @dev Configuration of the public mint stage, including timing and price. + /// @notice Public mints occur only if the current timestamp is within [startTime, endTime]. + mapping(uint256 => PublicStage) internal _publicStages; // tokenId => publicStage + + /// @dev Configuration of the allowlist mint stage, including timing, price, and a merkle root for verification. + /// @notice Only addresses proven by a valid Merkle proof can mint during this stage. + mapping(uint256 => AllowlistStage) internal _allowlistStages; // tokenId => allowlistStage + + /*============================================================== + = EVENTS = + ==============================================================*/ + + /// @notice Emitted when the public mint stage is set. + event PublicStageSet(PublicStage stage); + + /// @notice Emitted when the allowlist mint stage is set. + event AllowlistStageSet(AllowlistStage stage); + + /// @notice Emitted when the payout recipient is set. + event PayoutRecipientSet(address newPayoutRecipient); + + /// @notice Emitted when a token is minted. + event TokenMinted(address indexed to, uint256 tokenId, uint256 qty); + + /*============================================================== + = ERRORS = + ==============================================================*/ + + /// @notice Thrown when attempting to mint during a public stage that is not currently active. + error PublicStageNotActive(); + + /// @notice Thrown when attempting to mint during an allowlist stage that is not currently active. + error AllowlistStageNotActive(); + + /// @notice Thrown when the provided ETH value for a mint is insufficient. + error RequiredValueNotMet(); + + /// @notice Thrown when the provided Merkle proof for an allowlist mint is invalid. + error InvalidProof(); + + /// @notice Thrown when a stage's start or end time configuration is invalid. + error InvalidStageTime(); + + /// @notice Thrown when the public stage timing conflicts with the allowlist stage timing. + error InvalidPublicStageTime(); + + /// @notice Thrown when the allowlist stage timing conflicts with the public stage timing. + error InvalidAllowlistStageTime(); + + /// @notice Thrown when the payout recipient is set to a zero address. + error PayoutRecipientCannotBeZeroAddress(); + + /*============================================================== + = INITIALIZERS = + ==============================================================*/ + + /// @notice Initializes the contract with a name, symbol, and owner. + /// @dev Can only be called once. It sets the owner, emits a deploy event, and prepares the token for minting stages. + /// @param _name The ERC-1155 name of the collection. + /// @param _symbol The ERC-1155 symbol of the collection. + /// @param _owner The address designated as the initial owner of the contract. + function initialize(string memory _name, string memory _symbol, address _owner) public initializer { + __ERC1155MagicDropMetadataCloneable__init(_name, _symbol, _owner); + } + + /*============================================================== + = PUBLIC WRITE METHODS = + ==============================================================*/ + + /// @notice Mints tokens during the public stage. + /// @dev Requires that the current time is within the configured public stage interval. + /// Reverts if the buyer does not send enough ETH, or if the wallet limit would be exceeded. + /// @param to The recipient address for the minted tokens. + /// @param tokenId The ID of the token to mint. + /// @param qty The number of tokens to mint. + function mintPublic(address to, uint256 tokenId, uint256 qty, bytes memory data) external payable { + PublicStage memory stage = _publicStages[tokenId]; + if (block.timestamp < stage.startTime || block.timestamp > stage.endTime) { + revert PublicStageNotActive(); + } + + uint256 requiredPayment = stage.price * qty; + if (msg.value != requiredPayment) { + revert RequiredValueNotMet(); + } + + if (_walletLimit[tokenId] > 0 && _totalMintedByUserPerToken[to][tokenId] + qty > _walletLimit[tokenId]) { + revert WalletLimitExceeded(tokenId); + } + + _increaseSupplyOnMint(to, tokenId, qty); + + _mint(to, tokenId, qty, data); + + if (stage.price != 0) { + _splitProceeds(); + } + + emit TokenMinted(to, tokenId, qty); + } + + /// @notice Mints tokens during the allowlist stage. + /// @dev Requires a valid Merkle proof and the current time within the allowlist stage interval. + /// Reverts if the buyer sends insufficient ETH or if the wallet limit is exceeded. + /// @param to The recipient address for the minted tokens. + /// @param tokenId The ID of the token to mint. + /// @param qty The number of tokens to mint. + /// @param proof The Merkle proof verifying `to` is eligible for the allowlist. + function mintAllowlist(address to, uint256 tokenId, uint256 qty, bytes32[] calldata proof, bytes memory data) + external + payable + { + AllowlistStage memory stage = _allowlistStages[tokenId]; + if (block.timestamp < stage.startTime || block.timestamp > stage.endTime) { + revert AllowlistStageNotActive(); + } + + if (!MerkleProofLib.verify(proof, stage.merkleRoot, keccak256(bytes.concat(keccak256(abi.encode(to)))))) { + revert InvalidProof(); + } + + uint256 requiredPayment = stage.price * qty; + if (msg.value != requiredPayment) { + revert RequiredValueNotMet(); + } + + if (_walletLimit[tokenId] > 0 && _totalMintedByUserPerToken[to][tokenId] + qty > _walletLimit[tokenId]) { + revert WalletLimitExceeded(tokenId); + } + + _increaseSupplyOnMint(to, tokenId, qty); + + if (stage.price != 0) { + _splitProceeds(); + } + + _mint(to, tokenId, qty, data); + emit TokenMinted(to, tokenId, qty); + } + + /// @notice Burns a specific quantity of tokens on behalf of a given address. + /// @dev Reduces the total supply and calls the internal `_burn` function. + /// @param from The address from which the tokens will be burned. + /// @param id The ID of the token to burn. + /// @param qty The quantity of tokens to burn. + function burn(address from, uint256 id, uint256 qty) external { + _reduceSupplyOnBurn(id, qty); + _burn(msg.sender, from, id, qty); + } + + /// @notice Burns multiple types of tokens in a single batch operation. + /// @dev Iterates over each token ID and quantity to reduce supply and burn tokens. + /// @param from The address from which the tokens will be burned. + /// @param ids An array of token IDs to burn. + /// @param qty An array of quantities corresponding to each token ID to burn. + function batchBurn(address from, uint256[] calldata ids, uint256[] calldata qty) external { + uint256 length = ids.length; + for (uint256 i = 0; i < length;) { + _reduceSupplyOnBurn(ids[i], qty[i]); + unchecked { + ++i; + } + } + + _batchBurn(msg.sender, from, ids, qty); + } + + /*============================================================== + = PUBLIC VIEW METHODS = + ==============================================================*/ + + /// @notice Returns the current configuration of the contract. + /// @return The current configuration of the contract. + function getConfig(uint256 tokenId) external view returns (SetupConfig memory) { + SetupConfig memory newConfig = SetupConfig({ + tokenId: tokenId, + maxSupply: _tokenSupply[tokenId].maxSupply, + walletLimit: _walletLimit[tokenId], + baseURI: _baseURI, + contractURI: _contractURI, + allowlistStage: _allowlistStages[tokenId], + publicStage: _publicStages[tokenId], + payoutRecipient: _payoutRecipient, + royaltyRecipient: _royaltyReceiver, + royaltyBps: _royaltyBps + }); + + return newConfig; + } + + /// @notice Returns the current public stage configuration (startTime, endTime, price). + /// @return The current public stage settings. + function getPublicStage(uint256 tokenId) external view returns (PublicStage memory) { + return _publicStages[tokenId]; + } + + /// @notice Returns the current allowlist stage configuration (startTime, endTime, price, merkleRoot). + /// @return The current allowlist stage settings. + function getAllowlistStage(uint256 tokenId) external view returns (AllowlistStage memory) { + return _allowlistStages[tokenId]; + } + + /// @notice Returns the current payout recipient who receives primary sales proceeds after protocol fees. + /// @return The address currently set to receive payout funds. + function payoutRecipient() external view returns (address) { + return _payoutRecipient; + } + + /// @notice Indicates whether the contract implements a given interface. + /// @param interfaceId The interface ID to check for support. + /// @return True if the interface is supported, false otherwise. + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(ERC1155MagicDropMetadataCloneable) + returns (bool) + { + return interfaceId == type(IERC1155MagicDropMetadata).interfaceId || super.supportsInterface(interfaceId); + } + + /*============================================================== + = ADMIN OPERATIONS = + ==============================================================*/ + + /// @notice Sets up the contract parameters in a single call. + /// @dev Only callable by the owner. Configures max supply, wallet limit, URIs, stages, payout recipient. + /// @param config A struct containing all setup parameters. + function setup(SetupConfig calldata config) external onlyOwner { + if (config.maxSupply > 0) { + _setMaxSupply(config.tokenId, config.maxSupply); + } + + if (config.walletLimit > 0) { + _setWalletLimit(config.tokenId, config.walletLimit); + } + + if (bytes(config.baseURI).length > 0) { + _setBaseURI(config.baseURI); + } + + if (bytes(config.contractURI).length > 0) { + _setContractURI(config.contractURI); + } + + if (config.allowlistStage.startTime != 0 || config.allowlistStage.endTime != 0) { + _setAllowlistStage(config.tokenId, config.allowlistStage); + } + + if (config.publicStage.startTime != 0 || config.publicStage.endTime != 0) { + _setPublicStage(config.tokenId, config.publicStage); + } + + if (config.payoutRecipient != address(0)) { + _setPayoutRecipient(config.payoutRecipient); + } + + if (config.royaltyRecipient != address(0)) { + _setRoyaltyInfo(config.royaltyRecipient, config.royaltyBps); + } + } + + /// @notice Sets the configuration of the public mint stage. + /// @dev Only callable by the owner. Ensures the public stage does not overlap improperly with the allowlist stage. + /// @param stage A struct defining the public stage timing and price. + function setPublicStage(uint256 tokenId, PublicStage calldata stage) external onlyOwner { + _setPublicStage(tokenId, stage); + } + + /// @notice Sets the configuration of the allowlist mint stage. + /// @dev Only callable by the owner. Ensures the allowlist stage does not overlap improperly with the public stage. + /// @param stage A struct defining the allowlist stage timing, price, and merkle root. + function setAllowlistStage(uint256 tokenId, AllowlistStage calldata stage) external onlyOwner { + _setAllowlistStage(tokenId, stage); + } + + /// @notice Sets the payout recipient address for primary sale proceeds (after the protocol fee is deducted). + /// @dev Only callable by the owner. + /// @param newPayoutRecipient The address to receive future withdrawals. + function setPayoutRecipient(address newPayoutRecipient) external onlyOwner { + _setPayoutRecipient(newPayoutRecipient); + } + + /*============================================================== + = INTERNAL HELPERS = + ==============================================================*/ + + /// @notice Internal function to set the public mint stage configuration. + /// @dev Reverts if timing is invalid or conflicts with the allowlist stage. + /// @param stage A struct defining public stage timings and price. + function _setPublicStage(uint256 tokenId, PublicStage calldata stage) internal { + if (stage.startTime >= stage.endTime) { + revert InvalidStageTime(); + } + + // Ensure the public stage starts after the allowlist stage ends + if (_allowlistStages[tokenId].startTime != 0 && _allowlistStages[tokenId].endTime != 0) { + if (stage.startTime <= _allowlistStages[tokenId].endTime) { + revert InvalidPublicStageTime(); + } + } + + _publicStages[tokenId] = stage; + emit PublicStageSet(stage); + } + + /// @notice Internal function to set the allowlist mint stage configuration. + /// @dev Reverts if timing is invalid or conflicts with the public stage. + /// @param tokenId The ID of the token to set the allowlist stage for. + /// @param stage A struct defining allowlist stage timings, price, and merkle root. + function _setAllowlistStage(uint256 tokenId, AllowlistStage calldata stage) internal { + if (stage.startTime >= stage.endTime) { + revert InvalidStageTime(); + } + + // Ensure the public stage starts after the allowlist stage ends + if (_publicStages[tokenId].startTime != 0 && _publicStages[tokenId].endTime != 0) { + if (stage.endTime >= _publicStages[tokenId].startTime) { + revert InvalidAllowlistStageTime(); + } + } + + _allowlistStages[tokenId] = stage; + emit AllowlistStageSet(stage); + } + + /// @notice Internal function to set the payout recipient. + /// @dev This function does not revert if given a zero address, but no payouts would succeed if so. + /// @param newPayoutRecipient The address to receive the payout from mint proceeds. + function _setPayoutRecipient(address newPayoutRecipient) internal { + _payoutRecipient = newPayoutRecipient; + emit PayoutRecipientSet(newPayoutRecipient); + } + + /// @notice Internal function to split the proceeds of a mint. + /// @dev This function is called by the mint functions to split the proceeds into a protocol fee and a payout. + function _splitProceeds() internal { + if (_payoutRecipient == address(0)) { + revert PayoutRecipientCannotBeZeroAddress(); + } + + if (PROTOCOL_FEE_BPS > 0) { + uint256 protocolFee = (msg.value * PROTOCOL_FEE_BPS) / BPS_DENOMINATOR; + uint256 remainingBalance; + unchecked { + remainingBalance = msg.value - protocolFee; + } + SafeTransferLib.safeTransferETH(PROTOCOL_FEE_RECIPIENT, protocolFee); + SafeTransferLib.safeTransferETH(_payoutRecipient, remainingBalance); + } else { + SafeTransferLib.safeTransferETH(_payoutRecipient, msg.value); + } + } + + /// @notice Internal function to reduce the total supply when tokens are burned. + /// @dev Decreases the `totalSupply` for a given `tokenId` by the specified `qty`. + /// Uses `unchecked` to save gas, assuming that underflow is impossible + /// because burn operations should not exceed the current supply. + /// @param tokenId The ID of the token being burned. + /// @param qty The quantity of tokens to burn. + function _reduceSupplyOnBurn(uint256 tokenId, uint256 qty) internal { + TokenSupply storage supply = _tokenSupply[tokenId]; + unchecked { + supply.totalSupply -= uint64(qty); + } + } + + /// @notice Internal function to increase the total supply when tokens are minted. + /// @dev Increases the `totalSupply` and `totalMinted` for a given `tokenId` by the specified `qty`. + /// Ensures that the new total minted amount does not exceed the `maxSupply`. + /// Uses `unchecked` to save gas, assuming that overflow is impossible + /// because the maximum values are constrained by `maxSupply`. + /// @param to The address receiving the minted tokens. + /// @param tokenId The ID of the token being minted. + /// @param qty The quantity of tokens to mint. + /// @custom:reverts {CannotExceedMaxSupply} If the minting would exceed the maximum supply for the `tokenId`. + function _increaseSupplyOnMint(address to, uint256 tokenId, uint256 qty) internal { + TokenSupply storage supply = _tokenSupply[tokenId]; + + if (supply.totalMinted + qty > supply.maxSupply) { + revert CannotExceedMaxSupply(); + } + + unchecked { + supply.totalSupply += uint64(qty); + supply.totalMinted += uint64(qty); + _totalMintedByUserPerToken[to][tokenId] += uint64(qty); + } + } + + /*============================================================== + = META = + ==============================================================*/ + + /// @notice Returns the contract name and version. + /// @dev Useful for external tools or metadata standards. + /// @return The contract name and version strings. + function contractNameAndVersion() public pure returns (string memory, string memory) { + return ("ERC1155MagicDropCloneable", "1.0.0"); + } + + /*============================================================== + = MISC = + ==============================================================*/ + + /// @dev Overridden to allow this contract to properly manage owner initialization. + /// By always returning true, we ensure that the inherited initializer does not re-run. + function _guardInitializeOwner() internal pure virtual override returns (bool) { + return true; + } +} diff --git a/contracts/nft/erc1155m/clones/ERC1155MagicDropMetadataCloneable.sol b/contracts/nft/erc1155m/clones/ERC1155MagicDropMetadataCloneable.sol new file mode 100644 index 00000000..76f19fee --- /dev/null +++ b/contracts/nft/erc1155m/clones/ERC1155MagicDropMetadataCloneable.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {ERC2981} from "solady/src/tokens/ERC2981.sol"; +import {Ownable} from "solady/src/auth/Ownable.sol"; +import {Initializable} from "solady/src/utils/Initializable.sol"; + +import {ERC1155} from "solady/src/tokens/ERC1155.sol"; +import {IERC1155MagicDropMetadata} from "../interfaces/IERC1155MagicDropMetadata.sol"; +import {ERC1155ConduitPreapprovedCloneable} from "./ERC1155ConduitPreapprovedCloneable.sol"; + +/// @title ERC1155MagicDropMetadataCloneable +/// @notice A cloneable ERC-1155 implementation that supports adjustable metadata URIs, royalty configuration. +/// Inherits conduit-based preapprovals, making distribution more gas-efficient. +contract ERC1155MagicDropMetadataCloneable is + ERC1155ConduitPreapprovedCloneable, + IERC1155MagicDropMetadata, + ERC2981, + Ownable, + Initializable +{ + /// @dev The name of the collection. + string internal _name; + + /// @dev The symbol of the collection. + string internal _symbol; + + /// @dev The contract URI. + string internal _contractURI; + + /// @dev The base URI for the collection. + string internal _baseURI; + + /// @dev The address that receives royalty payments. + address internal _royaltyReceiver; + + /// @dev The royalty basis points. + uint96 internal _royaltyBps; + + /// @dev The total supply of each token. + mapping(uint256 => TokenSupply) internal _tokenSupply; + + /// @dev The maximum number of tokens that can be minted by a single wallet. + mapping(uint256 => uint256) internal _walletLimit; + + /// @dev The total number of tokens minted by each user per token. + mapping(address => mapping(uint256 => uint256)) internal _totalMintedByUserPerToken; + + /*============================================================== + = INITIALIZERS = + ==============================================================*/ + + /// @notice Initializes the contract with a name, symbol, and owner. + /// @dev Can only be called once. It sets the owner, emits a deploy event, and prepares the token for minting stages. + /// @param name_ The ERC-1155 name of the collection. + /// @param symbol_ The ERC-1155 symbol of the collection. + /// @param owner_ The address designated as the initial owner of the contract. + function __ERC1155MagicDropMetadataCloneable__init(string memory name_, string memory symbol_, address owner_) + internal + onlyInitializing + { + _name = name_; + _symbol = symbol_; + _initializeOwner(owner_); + + emit MagicDropTokenDeployed(); + } + + /*============================================================== + = PUBLIC VIEW METHODS = + ==============================================================*/ + + /// @notice Returns the name of the collection. + function name() public view returns (string memory) { + return _name; + } + + /// @notice Returns the symbol of the collection. + function symbol() public view returns (string memory) { + return _symbol; + } + + /// @notice Returns the current base URI used to construct token URIs. + function baseURI() public view override returns (string memory) { + return _baseURI; + } + + /// @notice Returns a URI representing contract-level metadata, often used by marketplaces. + function contractURI() public view override returns (string memory) { + return _contractURI; + } + + /// @notice The address designated to receive royalty payments on secondary sales. + /// @return The royalty receiver address. + function royaltyAddress() public view returns (address) { + return _royaltyReceiver; + } + + /// @notice The royalty rate in basis points (e.g. 100 = 1%) for secondary sales. + /// @return The royalty fee in basis points. + function royaltyBps() public view returns (uint256) { + return _royaltyBps; + } + + /// @notice The maximum number of tokens that can ever be minted by this contract. + /// @param tokenId The ID of the token. + /// @return The maximum supply of tokens. + function maxSupply(uint256 tokenId) public view returns (uint256) { + return _tokenSupply[tokenId].maxSupply; + } + + /// @notice Return the total supply of a token. + /// @param tokenId The ID of the token. + /// @return The total supply of token. + function totalSupply(uint256 tokenId) public view returns (uint256) { + return _tokenSupply[tokenId].totalSupply; + } + + /// @notice Return the total number of tokens minted for a specific token. + /// @param tokenId The ID of the token. + /// @return The total number of tokens minted. + function totalMinted(uint256 tokenId) public view returns (uint256) { + return _tokenSupply[tokenId].totalMinted; + } + + /// @notice Return the maximum number of tokens any single wallet can mint for a specific token. + /// @param tokenId The ID of the token. + /// @return The minting limit per wallet. + function walletLimit(uint256 tokenId) public view returns (uint256) { + return _walletLimit[tokenId]; + } + + /// @notice Indicates whether this contract implements a given interface. + /// @dev Supports ERC-2981 (royalties) and ERC-4906 (batch metadata updates), in addition to inherited interfaces. + /// @param interfaceId The interface ID to check for compliance. + /// @return True if the contract implements the specified interface, otherwise false. + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC1155, ERC2981) returns (bool) { + return interfaceId == 0x2a55205a // ERC-2981 royalties + || interfaceId == 0x49064906 // ERC-4906 metadata updates + || interfaceId == type(IERC1155MagicDropMetadata).interfaceId || ERC1155.supportsInterface(interfaceId); + } + + /// @notice Returns the URI for a given token ID. + /// @dev This returns the base URI for all tokens. + /// @return The URI for the token. + function uri(uint256 /* tokenId */ ) public view override returns (string memory) { + return _baseURI; + } + + /*============================================================== + = ADMIN OPERATIONS = + ==============================================================*/ + + /// @notice Sets a new base URI for token metadata, affecting all tokens. + /// @dev Emits a batch metadata update event if there are already minted tokens. + /// @param newBaseURI The new base URI. + function setBaseURI(string calldata newBaseURI) external override onlyOwner { + _setBaseURI(newBaseURI); + } + + /// @notice Updates the contract-level metadata URI. + /// @dev Useful for marketplaces to display project details. + /// @param newContractURI The new contract metadata URI. + function setContractURI(string calldata newContractURI) external override onlyOwner { + _setContractURI(newContractURI); + } + + /// @notice Adjusts the maximum token supply. + /// @dev Cannot increase beyond the original max supply. Cannot set below the current minted amount. + /// @param tokenId The ID of the token to update. + /// @param newMaxSupply The new maximum supply. + function setMaxSupply(uint256 tokenId, uint256 newMaxSupply) external onlyOwner { + _setMaxSupply(tokenId, newMaxSupply); + } + + /// @notice Updates the per-wallet minting limit. + /// @dev This can be changed at any time to adjust distribution constraints. + /// @param tokenId The ID of the token. + /// @param newWalletLimit The new per-wallet limit on minted tokens. + function setWalletLimit(uint256 tokenId, uint256 newWalletLimit) external onlyOwner { + _setWalletLimit(tokenId, newWalletLimit); + } + + /// @notice Configures the royalty information for secondary sales. + /// @dev Sets a new receiver and basis points for royalties. Basis points define the percentage rate. + /// @param newReceiver The address to receive royalties. + /// @param newBps The royalty rate in basis points (e.g., 100 = 1%). + function setRoyaltyInfo(address newReceiver, uint96 newBps) external onlyOwner { + _setRoyaltyInfo(newReceiver, newBps); + } + + /// @notice Emits an event to notify clients of metadata changes for a specific token range. + /// @dev Useful for updating external indexes after significant metadata alterations. + /// @param fromTokenId The starting token ID in the updated range. + /// @param toTokenId The ending token ID in the updated range. + function emitBatchMetadataUpdate(uint256 fromTokenId, uint256 toTokenId) external onlyOwner { + emit BatchMetadataUpdate(fromTokenId, toTokenId); + } + + /*============================================================== + = INTERNAL HELPERS = + ==============================================================*/ + + /// @notice Internal function setting the base URI for token metadata. + /// @param newBaseURI The new base URI string. + function _setBaseURI(string calldata newBaseURI) internal { + _baseURI = newBaseURI; + + // Notify EIP-4906 compliant observers of a metadata update. + emit BatchMetadataUpdate(0, type(uint256).max); + } + + /// @notice Internal function setting the contract URI. + /// @param newContractURI The new contract metadata URI. + function _setContractURI(string calldata newContractURI) internal { + _contractURI = newContractURI; + + emit ContractURIUpdated(newContractURI); + } + + /// @notice Internal function setting the royalty information. + /// @param newReceiver The address to receive royalties. + /// @param newBps The royalty rate in basis points (e.g., 100 = 1%). + function _setRoyaltyInfo(address newReceiver, uint96 newBps) internal { + _royaltyReceiver = newReceiver; + _royaltyBps = newBps; + super._setDefaultRoyalty(newReceiver, newBps); + emit RoyaltyInfoUpdated(newReceiver, newBps); + } + + /// @notice Internal function setting the maximum token supply. + /// @dev Cannot increase beyond the original max supply. Cannot set below the current minted amount. + /// @param tokenId The ID of the token. + /// @param newMaxSupply The new maximum supply. + function _setMaxSupply(uint256 tokenId, uint256 newMaxSupply) internal { + uint256 oldMaxSupply = _tokenSupply[tokenId].maxSupply; + if (oldMaxSupply != 0 && newMaxSupply > oldMaxSupply) { + revert MaxSupplyCannotBeIncreased(); + } + + if (newMaxSupply < _tokenSupply[tokenId].totalMinted) { + revert MaxSupplyCannotBeLessThanCurrentSupply(); + } + + if (newMaxSupply > 2 ** 64 - 1) { + revert MaxSupplyCannotBeGreaterThan2ToThe64thPower(); + } + + _tokenSupply[tokenId].maxSupply = uint64(newMaxSupply); + + emit MaxSupplyUpdated(tokenId, oldMaxSupply, newMaxSupply); + } + + /// @notice Internal function setting the per-wallet minting limit. + /// @param tokenId The ID of the token. + /// @param newWalletLimit The new per-wallet limit on minted tokens. + function _setWalletLimit(uint256 tokenId, uint256 newWalletLimit) internal { + _walletLimit[tokenId] = newWalletLimit; + emit WalletLimitUpdated(tokenId, newWalletLimit); + } +} diff --git a/contracts/nft/erc1155m/clones/Types.sol b/contracts/nft/erc1155m/clones/Types.sol new file mode 100644 index 00000000..ac33d149 --- /dev/null +++ b/contracts/nft/erc1155m/clones/Types.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +struct PublicStage { + /// @dev The start time of the public mint stage. + uint256 startTime; + /// @dev The end time of the public mint stage. + uint256 endTime; + /// @dev The price of the public mint stage. + uint256 price; +} + +struct AllowlistStage { + /// @dev The start time of the allowlist mint stage. + uint256 startTime; + /// @dev The end time of the allowlist mint stage. + uint256 endTime; + /// @dev The price of the allowlist mint stage. + uint256 price; + /// @dev The merkle root of the allowlist. + bytes32 merkleRoot; +} + +struct SetupConfig { + /// @dev The token ID of the token. + uint256 tokenId; + /// @dev The maximum number of tokens that can be minted. + /// - Can be decreased if current supply < new max supply + /// - Cannot be increased once set + uint256 maxSupply; + /// @dev The maximum number of tokens that can be minted per wallet + /// @notice A value of 0 indicates unlimited mints per wallet + uint256 walletLimit; + /// @dev The base URI of the token. + string baseURI; + /// @dev The contract URI of the token. + string contractURI; + /// @dev The public mint stage. + PublicStage publicStage; + /// @dev The allowlist mint stage. + AllowlistStage allowlistStage; + /// @dev The payout recipient of the token. + address payoutRecipient; + /// @dev The royalty recipient of the token. + address royaltyRecipient; + /// @dev The royalty basis points of the token. + uint96 royaltyBps; +} diff --git a/contracts/nft/erc1155m/interfaces/IERC1155MagicDropMetadata.sol b/contracts/nft/erc1155m/interfaces/IERC1155MagicDropMetadata.sol new file mode 100644 index 00000000..78e4cf9d --- /dev/null +++ b/contracts/nft/erc1155m/interfaces/IERC1155MagicDropMetadata.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {IMagicDropMetadata} from "contracts/common/interfaces/IMagicDropMetadata.sol"; + +interface IERC1155MagicDropMetadata is IMagicDropMetadata { + struct TokenSupply { + /// @notice The maximum number of tokens that can be minted. + uint64 maxSupply; + /// @notice The total number of tokens minted minus the number of tokens burned. + uint64 totalSupply; + /// @notice The total number of tokens minted. + uint64 totalMinted; + } + + /*============================================================== + = EVENTS = + ==============================================================*/ + + /// @notice Emitted when the max supply is updated. + /// @param _tokenId The token ID. + /// @param _oldMaxSupply The old max supply. + /// @param _newMaxSupply The new max supply. + event MaxSupplyUpdated(uint256 _tokenId, uint256 _oldMaxSupply, uint256 _newMaxSupply); + + /// @notice Emitted when the wallet limit is updated. + /// @param _tokenId The token ID. + /// @param _walletLimit The new wallet limit. + event WalletLimitUpdated(uint256 _tokenId, uint256 _walletLimit); + + /*============================================================== + = ERRORS = + ==============================================================*/ + + /// @notice Thrown when a mint would exceed the wallet-specific minting limit. + /// @param _tokenId The token ID. + error WalletLimitExceeded(uint256 _tokenId); + + /*============================================================== + = PUBLIC VIEW METHODS = + ==============================================================*/ + + /// @notice Returns the name of the token + function name() external view returns (string memory); + + /// @notice Returns the symbol of the token + function symbol() external view returns (string memory); + + /// @notice Returns the maximum number of tokens that can be minted + /// @dev This value cannot be increased once set, only decreased + /// @param tokenId The ID of the token + /// @return The maximum supply cap for the collection + function maxSupply(uint256 tokenId) external view returns (uint256); + + /// @notice Returns the total number of tokens minted minus the number of tokens burned + /// @param tokenId The ID of the token + /// @return The total number of tokens minted minus the number of tokens burned + function totalSupply(uint256 tokenId) external view returns (uint256); + + /// @notice Returns the total number of tokens minted + /// @param tokenId The ID of the token + /// @return The total number of tokens minted + function totalMinted(uint256 tokenId) external view returns (uint256); + + /// @notice Returns the maximum number of tokens that can be minted per wallet + /// @dev Used to prevent excessive concentration of tokens in single wallets + /// @param tokenId The ID of the token + /// @return The maximum number of tokens allowed per wallet address + function walletLimit(uint256 tokenId) external view returns (uint256); + + /*============================================================== + = ADMIN OPERATIONS = + ==============================================================*/ + + /// @notice Updates the maximum supply cap for the collection + /// @dev Can only decrease the max supply, never increase it + /// Must be greater than or equal to the current total supply + /// @param tokenId The ID of the token. + /// @param newMaxSupply The new maximum number of tokens that can be minted + function setMaxSupply(uint256 tokenId, uint256 newMaxSupply) external; + + /// @notice Updates the per-wallet token holding limit + /// @dev Used to prevent token concentration and ensure fair distribution + /// Setting this to 0 effectively removes the wallet limit + /// @param tokenId The ID of the token. + /// @param walletLimit The new maximum number of tokens allowed per wallet + function setWalletLimit(uint256 tokenId, uint256 walletLimit) external; +} diff --git a/foundry.toml b/foundry.toml index 1c70be40..2b323b6b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -12,3 +12,4 @@ optimizer_runs = 777 [etherscan] apechain = {key = "${VERIFICATION_API_KEY_APECHAIN}", chain = 33139, url = "https://api.apescan.io/api"} sei = {key = "${VERIFICATION_API_KEY_SEI}", chain = 1329, url = "https://api.seiscan.io/api"} + diff --git a/lib/solady b/lib/solady index 362b2efd..6122858a 160000 --- a/lib/solady +++ b/lib/solady @@ -1 +1 @@ -Subproject commit 362b2efd20f38aea7252b391e5e016633ff79641 +Subproject commit 6122858a3aed96ee9493b99f70a245237681a95f diff --git a/test/erc1155m/clones/ERC1155MagicDropCloneable.t.sol b/test/erc1155m/clones/ERC1155MagicDropCloneable.t.sol new file mode 100644 index 00000000..762a7538 --- /dev/null +++ b/test/erc1155m/clones/ERC1155MagicDropCloneable.t.sol @@ -0,0 +1,579 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; + +import {LibClone} from "solady/src/utils/LibClone.sol"; +import {MerkleProofLib} from "solady/src/utils/MerkleProofLib.sol"; + +import {MerkleTestHelper} from "test/helpers/MerkleTestHelper.sol"; + +import {ERC1155MagicDropCloneable} from "contracts/nft/erc1155m/clones/ERC1155MagicDropCloneable.sol"; +import {PublicStage, AllowlistStage, SetupConfig} from "contracts/nft/erc1155m/clones/Types.sol"; +import {IERC1155MagicDropMetadata} from "contracts/nft/erc1155m/interfaces/IERC1155MagicDropMetadata.sol"; +import {IMagicDropMetadata} from "contracts/common/interfaces/IMagicDropMetadata.sol"; + +contract ERC1155MagicDropCloneableTest is Test { + ERC1155MagicDropCloneable public token; + MerkleTestHelper public merkleHelper; + + address internal owner = address(0x1234); + address internal user = address(0x1111); + address internal user2 = address(0x2222); + address internal allowedAddr = address(0x3333); + address internal payoutRecipient = address(0x9999); + uint256 internal publicStart; + uint256 internal publicEnd; + uint256 internal allowlistStart; + uint256 internal allowlistEnd; + address royaltyRecipient = address(0x8888); + uint96 royaltyBps = 1000; + + uint256 internal tokenId = 1; + + SetupConfig internal config; + + function setUp() public { + // Prepare an array of addresses for testing allowlist + address[] memory addresses = new address[](1); + addresses[0] = allowedAddr; + // Deploy the new MerkleTestHelper with multiple addresses + merkleHelper = new MerkleTestHelper(addresses); + + token = ERC1155MagicDropCloneable(LibClone.deployERC1967(address(new ERC1155MagicDropCloneable()))); + + // Initialize token + token.initialize("TestToken", "TT", owner); + + // Default stages + allowlistStart = block.timestamp + 100; + allowlistEnd = block.timestamp + 200; + + publicStart = block.timestamp + 300; + publicEnd = block.timestamp + 400; + + config = SetupConfig({ + tokenId: tokenId, + maxSupply: 1000, + walletLimit: 5, + baseURI: "https://example.com/metadata/", + contractURI: "https://example.com/contract-metadata.json", + allowlistStage: AllowlistStage({ + startTime: uint64(allowlistStart), + endTime: uint64(allowlistEnd), + price: 0.005 ether, + merkleRoot: merkleHelper.getRoot() + }), + publicStage: PublicStage({startTime: uint64(publicStart), endTime: uint64(publicEnd), price: 0.01 ether}), + payoutRecipient: payoutRecipient, + royaltyRecipient: royaltyRecipient, + royaltyBps: royaltyBps + }); + + vm.prank(owner); + token.setup(config); + } + + /*============================================================== + = TEST INITIALIZATION / SETUP = + ==============================================================*/ + + function testInitialization() public { + assertEq(token.owner(), owner); + assertEq(token.name(), "TestToken"); + assertEq(token.symbol(), "TT"); + } + + function testReinitializeReverts() public { + vm.prank(owner); + vm.expectRevert(); // The contract should revert if trying to re-initialize + token.initialize("ReInit", "RI", owner); + } + + /*============================================================== + = TEST PUBLIC MINTING STAGE = + ==============================================================*/ + + function testMintPublicHappyPath() public { + // Move to public sale time + vm.warp(publicStart + 1); + + vm.deal(user, 1 ether); + + vm.prank(user); + token.mintPublic{value: 0.01 ether}(user, tokenId, 1, ""); + + assertEq(token.balanceOf(user, tokenId), 1); + } + + function testMintPublicBeforeStartReverts() public { + // Before start + vm.warp(publicStart - 10); + vm.deal(user, 1 ether); + + vm.prank(user); + vm.expectRevert(ERC1155MagicDropCloneable.PublicStageNotActive.selector); + token.mintPublic{value: 0.01 ether}(user, tokenId, 1, ""); + } + + function testMintPublicAfterEndReverts() public { + // After end + vm.warp(publicEnd + 10); + vm.deal(user, 1 ether); + + vm.prank(user); + vm.expectRevert(ERC1155MagicDropCloneable.PublicStageNotActive.selector); + token.mintPublic{value: 0.01 ether}(user, tokenId, 1, ""); + } + + function testMintPublicNotEnoughValueReverts() public { + vm.warp(publicStart + 1); + vm.deal(user, 0.005 ether); + + vm.prank(user); + vm.expectRevert(ERC1155MagicDropCloneable.RequiredValueNotMet.selector); + token.mintPublic{value: 0.005 ether}(user, tokenId, 1, ""); + } + + function testMintPublicWalletLimitExceededReverts() public { + vm.warp(publicStart + 1); + vm.deal(user, 1 ether); + + vm.startPrank(user); + // Mint up to the limit (5) + token.mintPublic{value: 0.05 ether}(user, tokenId, 5, ""); + assertEq(token.balanceOf(user, tokenId), 5); + + // Attempt to mint one more + vm.expectRevert(abi.encodeWithSelector(IERC1155MagicDropMetadata.WalletLimitExceeded.selector, tokenId)); + token.mintPublic{value: 0.01 ether}(user, tokenId, 1, ""); + vm.stopPrank(); + } + + function testMintPublicMaxSupplyExceededReverts() public { + vm.warp(publicStart + 1); + vm.deal(user, 10.01 ether); + + vm.prank(owner); + // unlimited wallet limit for the purpose of this test + token.setWalletLimit(tokenId, 0); + + vm.prank(user); + vm.expectRevert(IMagicDropMetadata.CannotExceedMaxSupply.selector); + token.mintPublic{value: 10.01 ether}(user, tokenId, 1001, ""); + } + + function testMintPublicOverpayReverts() public { + vm.warp(publicStart + 1); + + vm.deal(user, 1 ether); + + vm.prank(user); + vm.expectRevert(ERC1155MagicDropCloneable.RequiredValueNotMet.selector); + token.mintPublic{value: 0.02 ether}(user, tokenId, 1, ""); + } + + /*============================================================== + = TEST ALLOWLIST MINTING STAGE = + ==============================================================*/ + + function testMintAllowlistHappyPath() public { + // Move time to allowlist + vm.warp(allowlistStart + 1); + + vm.deal(allowedAddr, 1 ether); + vm.prank(allowedAddr); + + // Generate a proof for the allowedAddr from our new MerkleTestHelper + bytes32[] memory proof = merkleHelper.getProofFor(allowedAddr); + + token.mintAllowlist{value: 0.005 ether}(allowedAddr, tokenId, 1, proof, ""); + + assertEq(token.balanceOf(allowedAddr, tokenId), 1); + } + + function testMintAllowlistInvalidProofReverts() public { + vm.warp(allowlistStart + 1); + + bytes32[] memory proof = merkleHelper.getProofFor(allowedAddr); + + vm.deal(allowedAddr, 1 ether); + vm.prank(allowedAddr); + + vm.expectRevert(ERC1155MagicDropCloneable.InvalidProof.selector); + token.mintAllowlist{value: 0.005 ether}(user, tokenId, 1, proof, ""); + } + + function testMintAllowlistNotActiveReverts() public { + // Before allowlist start + vm.warp(allowlistStart - 10); + + bytes32[] memory proof = merkleHelper.getProofFor(allowedAddr); + vm.deal(allowedAddr, 1 ether); + vm.prank(allowedAddr); + + vm.expectRevert(ERC1155MagicDropCloneable.AllowlistStageNotActive.selector); + token.mintAllowlist{value: 0.005 ether}(allowedAddr, tokenId, 1, proof, ""); + } + + function testMintAllowlistNotEnoughValueReverts() public { + vm.warp(allowlistStart + 1); + + bytes32[] memory proof = merkleHelper.getProofFor(allowedAddr); + vm.deal(allowedAddr, 0.001 ether); + vm.prank(allowedAddr); + + vm.expectRevert(ERC1155MagicDropCloneable.RequiredValueNotMet.selector); + token.mintAllowlist{value: 0.001 ether}(allowedAddr, tokenId, 1, proof, ""); + } + + function testMintAllowlistWalletLimitExceededReverts() public { + vm.warp(allowlistStart + 1); + + bytes32[] memory proof = merkleHelper.getProofFor(allowedAddr); + vm.deal(allowedAddr, 1 ether); + + vm.startPrank(allowedAddr); + // Mint up to the limit + token.mintAllowlist{value: 0.025 ether}(allowedAddr, tokenId, 5, proof, ""); + assertEq(token.balanceOf(allowedAddr, tokenId), 5); + + vm.expectRevert(abi.encodeWithSelector(IERC1155MagicDropMetadata.WalletLimitExceeded.selector, tokenId)); + token.mintAllowlist{value: 0.005 ether}(allowedAddr, tokenId, 1, proof, ""); + vm.stopPrank(); + } + + function testMintAllowlistMaxSupplyExceededReverts() public { + vm.warp(allowlistStart + 1); + + vm.prank(owner); + // unlimited wallet limit for the purpose of this test + token.setWalletLimit(tokenId, 0); + + vm.deal(allowedAddr, 5.005 ether); + vm.prank(allowedAddr); + + bytes32[] memory proof = merkleHelper.getProofFor(allowedAddr); + + vm.expectRevert(IMagicDropMetadata.CannotExceedMaxSupply.selector); + token.mintAllowlist{value: 5.005 ether}(allowedAddr, tokenId, 1001, proof, ""); + } + + function testMintAllowlistOverpayReverts() public { + vm.warp(allowlistStart + 1); + + bytes32[] memory proof = merkleHelper.getProofFor(allowedAddr); + vm.deal(allowedAddr, 1 ether); + + vm.expectRevert(ERC1155MagicDropCloneable.RequiredValueNotMet.selector); + token.mintAllowlist{value: 0.02 ether}(allowedAddr, tokenId, 1, proof, ""); + } + + /*============================================================== + = BURNING = + ==============================================================*/ + + function testBurnHappyPath() public { + // Public mint first + vm.warp(publicStart + 1); + vm.deal(user, 1 ether); + + vm.prank(user); + token.mintPublic{value: 0.01 ether}(user, tokenId, 1, ""); + + assertEq(token.balanceOf(user, tokenId), 1); + + vm.prank(user); + token.burn(user, tokenId, 1); + + assertEq(token.balanceOf(user, tokenId), 0); + } + + function testBurnInvalidTokenReverts() public { + vm.prank(user); + vm.expectRevert(); + token.burn(user, 9999, 1); // non-existent token + } + + function testBurnNotOwnerReverts() public { + vm.warp(publicStart + 1); + vm.deal(user, 1 ether); + + vm.prank(user); + token.mintPublic{value: 0.01 ether}(user, tokenId, 1, ""); + + vm.prank(user2); + vm.expectRevert(); + token.burn(user, tokenId, 1); + } + + function testBurnFromAuthorizedNonOwner() public { + vm.warp(publicStart + 1); + vm.deal(user, 1 ether); + + vm.startPrank(user); + token.mintPublic{value: 0.01 ether}(user, tokenId, 1, ""); + token.setApprovalForAll(user2, true); + vm.stopPrank(); + + vm.prank(user2); + token.burn(user, tokenId, 1); + assertEq(token.balanceOf(user, tokenId), 0); + } + + function testBatchBurn() public { + vm.warp(publicStart + 1); + vm.deal(user, 1 ether); + + vm.startPrank(user); + token.mintPublic{value: 0.05 ether}(user, tokenId, 5, ""); + token.setApprovalForAll(user2, true); + vm.stopPrank(); + + assertEq(token.balanceOf(user, tokenId), 5); + assertEq(token.totalSupply(tokenId), 5); + + uint256[] memory ids = new uint256[](1); + ids[0] = tokenId; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 2; + + vm.prank(user); + token.batchBurn(user, ids, amounts); + assertEq(token.balanceOf(user, tokenId), 3); + assertEq(token.totalSupply(tokenId), 3); + assertEq(token.totalMinted(tokenId), 5); + } + + /*============================================================== + = GETTERS = + ==============================================================*/ + + function testGetPublicStage() public { + PublicStage memory ps = token.getPublicStage(tokenId); + assertEq(ps.startTime, publicStart); + assertEq(ps.endTime, publicEnd); + assertEq(ps.price, 0.01 ether); + } + + function testGetAllowlistStage() public view { + AllowlistStage memory als = token.getAllowlistStage(tokenId); + assertEq(als.startTime, allowlistStart); + assertEq(als.endTime, allowlistEnd); + assertEq(als.price, 0.005 ether); + assertEq(als.merkleRoot, merkleHelper.getRoot()); + } + + function testPayoutRecipient() public { + assertEq(token.payoutRecipient(), payoutRecipient); + } + + /*============================================================== + = SUPPORTSINTERFACE = + ==============================================================*/ + + function testSupportsInterface() public view { + // Just checks a known supported interface + assertTrue(token.supportsInterface(type(IERC1155MagicDropMetadata).interfaceId)); + } + + /*============================================================== + = ADMIN OPERATIONS = + ==============================================================*/ + + function testSetupRevertsNotOwner() public { + vm.prank(user); + vm.expectRevert(); + token.setup(config); + } + + function testSetupEmptyConfigHasNoEffect() public { + vm.prank(owner); + token.setup( + SetupConfig({ + tokenId: tokenId, + maxSupply: 0, + walletLimit: 0, + baseURI: "", + contractURI: "", + allowlistStage: AllowlistStage({startTime: uint64(0), endTime: uint64(0), price: 0, merkleRoot: bytes32(0)}), + publicStage: PublicStage({startTime: uint64(0), endTime: uint64(0), price: 0}), + payoutRecipient: address(0), + royaltyBps: 0, + royaltyRecipient: address(0) + }) + ); + + // check that the config has no effect because it's using the zero values + assertEq(token.maxSupply(tokenId), config.maxSupply); + assertEq(token.walletLimit(tokenId), config.walletLimit); + assertEq(token.baseURI(), config.baseURI); + assertEq(token.contractURI(), config.contractURI); + assertEq(token.getAllowlistStage(tokenId).startTime, config.allowlistStage.startTime); + assertEq(token.getAllowlistStage(tokenId).endTime, config.allowlistStage.endTime); + assertEq(token.getAllowlistStage(tokenId).price, config.allowlistStage.price); + assertEq(token.getAllowlistStage(tokenId).merkleRoot, config.allowlistStage.merkleRoot); + assertEq(token.getPublicStage(tokenId).startTime, config.publicStage.startTime); + assertEq(token.getPublicStage(tokenId).endTime, config.publicStage.endTime); + assertEq(token.getPublicStage(tokenId).price, config.publicStage.price); + assertEq(token.payoutRecipient(), config.payoutRecipient); + assertEq(token.royaltyAddress(), config.royaltyRecipient); + assertEq(token.royaltyBps(), config.royaltyBps); + } + + function testSetPublicStageInvalidTimesReverts() public { + PublicStage memory invalidStage = PublicStage({ + startTime: uint64(block.timestamp + 1000), + endTime: uint64(block.timestamp + 500), // end before start + price: 0.01 ether + }); + + vm.prank(owner); + vm.expectRevert(ERC1155MagicDropCloneable.InvalidStageTime.selector); + token.setPublicStage(tokenId, invalidStage); + } + + function testSetAllowlistStageInvalidTimesReverts() public { + AllowlistStage memory invalidStage = AllowlistStage({ + startTime: uint64(block.timestamp + 1000), + endTime: uint64(block.timestamp + 500), // end before start + price: 0.005 ether, + merkleRoot: merkleHelper.getRoot() + }); + + vm.prank(owner); + vm.expectRevert(ERC1155MagicDropCloneable.InvalidStageTime.selector); + token.setAllowlistStage(tokenId, invalidStage); + } + + function testSetPublicStageOverlapWithAllowlistReverts() public { + // Current allowlist starts at publicEnd+100 + // Try to set public stage that ends after that + PublicStage memory overlappingStage = PublicStage({ + startTime: uint64(block.timestamp + 10), + endTime: uint64(allowlistEnd + 150), + price: 0.01 ether + }); + + vm.prank(owner); + vm.expectRevert(ERC1155MagicDropCloneable.InvalidPublicStageTime.selector); + token.setPublicStage(tokenId, overlappingStage); + } + + function testSetAllowlistStageOverlapWithPublicReverts() public { + // Current public ends at publicEnd + // Try to set allowlist that ends before public ends + AllowlistStage memory overlappingStage = AllowlistStage({ + startTime: uint64(publicEnd - 50), + endTime: uint64(publicEnd + 10), + price: 0.005 ether, + merkleRoot: merkleHelper.getRoot() + }); + + vm.prank(owner); + vm.expectRevert(ERC1155MagicDropCloneable.InvalidAllowlistStageTime.selector); + token.setAllowlistStage(tokenId, overlappingStage); + } + + function testSetPayoutRecipient() public { + vm.prank(owner); + token.setPayoutRecipient(address(0x8888)); + assertEq(token.payoutRecipient(), address(0x8888)); + } + + /*============================================================== + = TEST SPLIT PROCEEDS = + ==============================================================*/ + + function testSplitProceeds() public { + // Move to public sale time + vm.warp(publicStart + 1); + + // Fund the user with enough ETH + vm.deal(user, 1 ether); + + // Check initial balances + uint256 initialProtocolBalance = token.PROTOCOL_FEE_RECIPIENT().balance; + uint256 initialPayoutBalance = payoutRecipient.balance; + + // User mints a token + vm.prank(user); + token.mintPublic{value: 0.01 ether}(user, tokenId, 1, ""); + + // Check balances after minting + uint256 expectedProtocolFee = (0.01 ether * token.PROTOCOL_FEE_BPS()) / token.BPS_DENOMINATOR(); + uint256 expectedPayout = 0.01 ether - expectedProtocolFee; + + assertEq(token.PROTOCOL_FEE_RECIPIENT().balance, initialProtocolBalance + expectedProtocolFee); + assertEq(payoutRecipient.balance, initialPayoutBalance + expectedPayout); + } + + function testSplitProceedsWithZeroPrice() public { + // Check initial balances + uint256 initialProtocolBalance = token.PROTOCOL_FEE_RECIPIENT().balance; + uint256 initialPayoutBalance = payoutRecipient.balance; + + vm.prank(owner); + token.setPublicStage( + tokenId, PublicStage({startTime: uint64(publicStart), endTime: uint64(publicEnd), price: 0}) + ); + + // Move to public sale time + vm.warp(publicStart + 1); + + // User mints a token with price 0 + vm.prank(user); + token.mintPublic{value: 0 ether}(user, tokenId, 1, ""); + + // Check balances after minting + assertEq(token.PROTOCOL_FEE_RECIPIENT().balance, initialProtocolBalance); + assertEq(payoutRecipient.balance, initialPayoutBalance); + } + + function testSplitProceedsAllowlist() public { + // Move to allowlist time + vm.warp(allowlistStart + 1); + + // Check initial balances + uint256 initialProtocolBalance = token.PROTOCOL_FEE_RECIPIENT().balance; + uint256 initialPayoutBalance = payoutRecipient.balance; + + vm.deal(allowedAddr, 1 ether); + vm.prank(allowedAddr); + token.mintAllowlist{value: 0.005 ether}(allowedAddr, tokenId, 1, merkleHelper.getProofFor(allowedAddr), ""); + + uint256 expectedProtocolFee = (0.005 ether * token.PROTOCOL_FEE_BPS()) / token.BPS_DENOMINATOR(); + uint256 expectedPayout = 0.005 ether - expectedProtocolFee; + + // Check balances after minting + assertEq(token.PROTOCOL_FEE_RECIPIENT().balance, initialProtocolBalance + expectedProtocolFee); + assertEq(payoutRecipient.balance, initialPayoutBalance + expectedPayout); + } + + function testSplitProceedsPayoutRecipientZeroAddressReverts() public { + // Move to public sale time + vm.warp(publicStart + 1); + + vm.prank(owner); + token.setPayoutRecipient(address(0)); + assertEq(token.payoutRecipient(), address(0)); + + vm.deal(user, 1 ether); + + vm.prank(user); + vm.expectRevert(ERC1155MagicDropCloneable.PayoutRecipientCannotBeZeroAddress.selector); + token.mintPublic{value: 0.01 ether}(user, tokenId, 1, ""); + } + + /*============================================================== + = MISC = + ==============================================================*/ + + function testContractNameAndVersion() public { + (string memory name, string memory version) = token.contractNameAndVersion(); + // check that a value is returned + assert(bytes(name).length > 0); + assert(bytes(version).length > 0); + } +} diff --git a/test/erc1155m/clones/ERC1155MagicDropMetadataCloneable.t.sol b/test/erc1155m/clones/ERC1155MagicDropMetadataCloneable.t.sol new file mode 100644 index 00000000..1d02976d --- /dev/null +++ b/test/erc1155m/clones/ERC1155MagicDropMetadataCloneable.t.sol @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {Test} from "forge-std/Test.sol"; +import {ERC1155} from "solady/src/tokens/ERC1155.sol"; + +import {Ownable} from "solady/src/auth/Ownable.sol"; +import {LibClone} from "solady/src/utils/LibClone.sol"; +import {Initializable} from "solady/src/utils/Initializable.sol"; + +import {IMagicDropMetadata} from "contracts/common/interfaces/IMagicDropMetadata.sol"; +import {IERC1155MagicDropMetadata} from "contracts/nft/erc1155m/interfaces/IERC1155MagicDropMetadata.sol"; +import {ERC1155MagicDropMetadataCloneable} from "contracts/nft/erc1155m/clones/ERC1155MagicDropMetadataCloneable.sol"; + +interface IERC2981 { + function royaltyInfo(uint256 tokenId, uint256 salePrice) external view returns (address, uint256); +} + +/// @dev A testable contract that exposes a mint function for testing scenarios that depend on having minted tokens. +contract TestableERC1155MagicDropMetadataCloneable is ERC1155MagicDropMetadataCloneable { + bool private _initialized; + + function initialize(string memory name_, string memory symbol_, address owner_) external initializer { + __ERC1155MagicDropMetadataCloneable__init(name_, symbol_, owner_); + _initialized = true; + } + + function mintForTest(address to, uint256 tokenId, uint256 quantity) external onlyOwner { + _totalMintedByUserPerToken[to][tokenId] += quantity; + _tokenSupply[tokenId].totalMinted += uint64(quantity); + _tokenSupply[tokenId].totalSupply += uint64(quantity); + + _mint(to, tokenId, quantity, ""); + } +} + +contract ERC1155MagicDropMetadataCloneableTest is Test { + TestableERC1155MagicDropMetadataCloneable token; + + address owner = address(0x1234); + address user = address(0xABCD); + address royaltyReceiver = address(0x9999); + + uint256 internal constant TOKEN_ID = 1; + uint256 internal constant TOKEN_ID_2 = 2; + + function setUp() public { + token = TestableERC1155MagicDropMetadataCloneable( + LibClone.deployERC1967(address(new TestableERC1155MagicDropMetadataCloneable())) + ); + token.initialize("Test Collection", "TST", owner); + } + + /*============================================================== + = INITIALIZATION = + ==============================================================*/ + + function testInitialization() public view { + assertEq(token.owner(), owner); + assertEq(token.name(), "Test Collection"); + assertEq(token.symbol(), "TST"); + assertEq(token.baseURI(), ""); + assertEq(token.contractURI(), ""); + // maxSupply, walletLimit, and royalty not set for tokenId yet + assertEq(token.maxSupply(TOKEN_ID), 0); + assertEq(token.walletLimit(TOKEN_ID), 0); + assertEq(token.totalSupply(TOKEN_ID), 0); + assertEq(token.totalMinted(TOKEN_ID), 0); + assertEq(token.royaltyAddress(), address(0)); + assertEq(token.royaltyBps(), 0); + } + + function testCannotInitializeTwice() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + token.initialize("Test Collection", "TST", owner); + } + + /*============================================================== + = ONLY OWNER TESTS = + ==============================================================*/ + + function testOnlyOwnerFunctions() public { + // Try calling setBaseURI as user + vm.prank(user); + vm.expectRevert(Ownable.Unauthorized.selector); + token.setBaseURI("ipfs://newbase/"); + + // Similarly test contractURI + vm.prank(user); + vm.expectRevert(Ownable.Unauthorized.selector); + token.setContractURI("https://new-contract-uri.json"); + + // setMaxSupply + vm.prank(user); + vm.expectRevert(Ownable.Unauthorized.selector); + token.setMaxSupply(TOKEN_ID, 1000); + + // setWalletLimit + vm.prank(user); + vm.expectRevert(Ownable.Unauthorized.selector); + token.setWalletLimit(TOKEN_ID, 20); + + // setRoyaltyInfo + vm.prank(user); + vm.expectRevert(Ownable.Unauthorized.selector); + token.setRoyaltyInfo(royaltyReceiver, 500); + } + + /*============================================================== + = ADMIN OPERATIONS = + ==============================================================*/ + + function testSetBaseURI() public { + vm.prank(owner); + token.setBaseURI("https://example.com/metadata/"); + assertEq(token.baseURI(), "https://example.com/metadata/"); + } + + function testSetContractURI() public { + vm.prank(owner); + token.setContractURI("https://new-contract-uri.json"); + assertEq(token.contractURI(), "https://new-contract-uri.json"); + } + + function testSetMaxSupplyBasic() public { + vm.startPrank(owner); + token.setMaxSupply(TOKEN_ID, 1000); + vm.stopPrank(); + assertEq(token.maxSupply(TOKEN_ID), 1000); + } + + function testSetMaxSupplyDecreaseNotBelowMinted() public { + vm.startPrank(owner); + token.mintForTest(user, TOKEN_ID, 10); + // Currently minted = 10 + vm.expectRevert(IMagicDropMetadata.MaxSupplyCannotBeLessThanCurrentSupply.selector); + token.setMaxSupply(TOKEN_ID, 5); + + // Setting exactly to 10 should pass if we first set initial max supply + token.setMaxSupply(TOKEN_ID, 1000); // set initial max supply + token.setMaxSupply(TOKEN_ID, 10); // now decrease to minted + assertEq(token.maxSupply(TOKEN_ID), 10); + vm.stopPrank(); + } + + function testSetMaxSupplyCannotIncreaseBeyondOriginal() public { + vm.startPrank(owner); + token.setMaxSupply(TOKEN_ID, 1000); + vm.expectRevert(IMagicDropMetadata.MaxSupplyCannotBeIncreased.selector); + token.setMaxSupply(TOKEN_ID, 2000); + vm.stopPrank(); + } + + function testSetMaxSupplyToCurrentSupply() public { + vm.startPrank(owner); + token.mintForTest(user, TOKEN_ID, 10); + token.setMaxSupply(TOKEN_ID, 10); + assertEq(token.maxSupply(TOKEN_ID), 10); + vm.stopPrank(); + } + + function testMintIncreasesTotalSupply() public { + vm.startPrank(owner); + token.mintForTest(user, TOKEN_ID, 10); + assertEq(token.totalSupply(TOKEN_ID), 10); + vm.stopPrank(); + } + + function testMintIncreasesTotalMinted() public { + vm.startPrank(owner); + token.mintForTest(user, TOKEN_ID, 10); + assertEq(token.totalMinted(TOKEN_ID), 10); + vm.stopPrank(); + } + + function testSetWalletLimit() public { + vm.startPrank(owner); + token.setWalletLimit(TOKEN_ID, 20); + assertEq(token.walletLimit(TOKEN_ID), 20); + vm.stopPrank(); + } + + function testSetZeroWalletLimit() public { + vm.startPrank(owner); + token.setWalletLimit(TOKEN_ID, 0); + assertEq(token.walletLimit(TOKEN_ID), 0); + vm.stopPrank(); + } + + function testSetRoyaltyInfo() public { + vm.startPrank(owner); + token.setRoyaltyInfo(royaltyReceiver, 500); + + assertEq(token.royaltyAddress(), royaltyReceiver); + assertEq(token.royaltyBps(), 500); + + // Check ERC2981 royaltyInfo + (address receiver, uint256 amount) = IERC2981(address(token)).royaltyInfo(TOKEN_ID, 10_000); + assertEq(receiver, royaltyReceiver); + assertEq(amount, 500); // 5% of 10000 = 500 + vm.stopPrank(); + } + + function testSetRoyaltyInfoZeroAddress() public { + vm.startPrank(owner); + vm.expectRevert(); + token.setRoyaltyInfo(address(0), 1000); + vm.stopPrank(); + } + + function testSetURI() public { + vm.startPrank(owner); + token.setBaseURI("https://example.com/metadata/"); + assertEq(token.uri(TOKEN_ID), "https://example.com/metadata/"); + vm.stopPrank(); + } + + function testEmitBatchMetadataUpdate() public { + vm.startPrank(owner); + vm.expectEmit(true, true, true, true); + emit IMagicDropMetadata.BatchMetadataUpdate(TOKEN_ID, 10); + token.emitBatchMetadataUpdate(TOKEN_ID, 10); + vm.stopPrank(); + } + + function testMaxSupplyCannotBeGreaterThan2ToThe64thPower() public { + vm.startPrank(owner); + vm.expectRevert(IMagicDropMetadata.MaxSupplyCannotBeGreaterThan2ToThe64thPower.selector); + token.setMaxSupply(TOKEN_ID, 2 ** 64); + vm.stopPrank(); + } + + /*============================================================== + = METADATA TESTS = + ==============================================================*/ + + function testSupportsInterface() public view { + // ERC2981 interfaceId = 0x2a55205a + assertTrue(token.supportsInterface(0x2a55205a)); + // ERC4906 interfaceId = 0x49064906 + assertTrue(token.supportsInterface(0x49064906)); + // ERC1155 interfaceId = 0xd9b67a26 + assertTrue(token.supportsInterface(0xd9b67a26)); + // Some random interface + assertFalse(token.supportsInterface(0x12345678)); + } +} diff --git a/test/helpers/MerkleTestHelper.sol b/test/helpers/MerkleTestHelper.sol new file mode 100644 index 00000000..ee46afac --- /dev/null +++ b/test/helpers/MerkleTestHelper.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +/** + * @title MerkleTestHelper + * @dev This contract builds a Merkle tree from a list of addresses, stores the root, + * and provides a function to retrieve a Merkle proof for a given address. + * + * NOTE: Generating Merkle trees on-chain is gas-expensive, so this is typically + * done only in testing scenarios or for very short lists. + */ +contract MerkleTestHelper { + address[] internal _allowedAddrs; + bytes32 internal _root; + + /** + * @dev Constructor that takes in an array of addresses, builds a Merkle tree, and stores the root. + */ + constructor(address[] memory allowedAddresses) { + // Copy addresses to storage + for (uint256 i = 0; i < allowedAddresses.length; i++) { + _allowedAddrs.push(allowedAddresses[i]); + } + + // Build leaves from the addresses + bytes32[] memory leaves = _buildLeaves(_allowedAddrs); + + // Compute merkle root + _root = _computeMerkleRoot(leaves); + } + + /** + * @notice Returns the Merkle root of the addresses list. + */ + function getRoot() external view returns (bytes32) { + return _root; + } + + /** + * @notice Returns the Merkle proof for a given address. + * @dev If the address is not found or is not part of the _allowedAddrs array, + * this will return an empty array. + */ + function getProofFor(address addr) external view returns (bytes32[] memory) { + // Find the index of the address in our stored list + (bool isInList, uint256 index) = _findAddressIndex(addr); + if (!isInList) { + // Return empty proof if address doesn't exist in the allowed list + return new bytes32[](0); + } + + // Build leaves in memory + bytes32[] memory leaves = _buildLeaves(_allowedAddrs); + + // Build the proof for the leaf at the found index + return _buildProof(leaves, index); + } + + /** + * @dev Creates an array of leaves by double hashing each address: + * keccak256(bytes.concat(keccak256(abi.encodePacked(address)))) + */ + function _buildLeaves(address[] memory addrs) internal pure returns (bytes32[] memory) { + bytes32[] memory leaves = new bytes32[](addrs.length); + for (uint256 i = 0; i < addrs.length; i++) { + leaves[i] = keccak256(bytes.concat(keccak256(abi.encode(addrs[i])))); + } + return leaves; + } + + /** + * @dev Computes the Merkle root from an array of leaves. + * Pairs each leaf, hashing them together until only one root remains. + * If there is an odd number of leaves at a given level, the last leaf is "promoted" (copied up). + */ + function _computeMerkleRoot(bytes32[] memory leaves) internal pure returns (bytes32) { + require(leaves.length > 0, "No leaves to build a merkle root"); + + uint256 n = leaves.length; + while (n > 1) { + for (uint256 i = 0; i < n / 2; i++) { + // Sort the pair before hashing + (bytes32 left, bytes32 right) = leaves[2 * i] < leaves[2 * i + 1] + ? (leaves[2 * i], leaves[2 * i + 1]) + : (leaves[2 * i + 1], leaves[2 * i]); + leaves[i] = keccak256(abi.encodePacked(left, right)); + } + // If odd, promote last leaf + if (n % 2 == 1) { + leaves[n / 2] = leaves[n - 1]; + n = (n / 2) + 1; + } else { + n = n / 2; + } + } + + // The first element is now the root + return leaves[0]; + } + + /** + * @dev Builds a Merkle proof for the leaf at the given index. + * We recompute the pairing tree on the fly, capturing the "sibling" each time. + */ + function _buildProof(bytes32[] memory leaves, uint256 targetIndex) internal pure returns (bytes32[] memory) { + bytes32[] memory proof = new bytes32[](_proofLength(leaves.length)); + uint256 proofPos = 0; + uint256 n = leaves.length; + uint256 index = targetIndex; + + while (n > 1) { + bool isIndexEven = (index % 2) == 0; + uint256 pairIndex = isIndexEven ? index + 1 : index - 1; + + if (pairIndex < n) { + // Add the sibling to the proof without sorting + proof[proofPos] = leaves[pairIndex]; + proofPos++; + } + + // Move up to the next level + for (uint256 i = 0; i < n / 2; i++) { + // Sort pairs when building the next level + (bytes32 left, bytes32 right) = leaves[2 * i] < leaves[2 * i + 1] + ? (leaves[2 * i], leaves[2 * i + 1]) + : (leaves[2 * i + 1], leaves[2 * i]); + leaves[i] = keccak256(abi.encodePacked(left, right)); + } + + // Handle odd number of leaves + if (n % 2 == 1) { + leaves[n / 2] = leaves[n - 1]; + n = (n / 2) + 1; + } else { + n = n / 2; + } + + index = index / 2; + } + + // Trim unused proof elements + uint256 trimSize = 0; + for (uint256 i = proof.length; i > 0; i--) { + if (proof[i - 1] != 0) { + break; + } + trimSize++; + } + + bytes32[] memory trimmedProof = new bytes32[](proof.length - trimSize); + for (uint256 i = 0; i < trimmedProof.length; i++) { + trimmedProof[i] = proof[i]; + } + + return trimmedProof; + } + + /** + * @dev Helper to find the index of a given address in the _allowedAddrs array. + */ + function _findAddressIndex(address addr) internal view returns (bool, uint256) { + for (uint256 i = 0; i < _allowedAddrs.length; i++) { + if (_allowedAddrs[i] == addr) { + return (true, i); + } + } + return (false, 0); + } + + /** + * @dev Computes an upper bound for the proof length (worst-case). + * For n leaves, the maximum proof length is ~log2(n). + * Here we just do a simple upper bound for clarity. + */ + function _proofLength(uint256 n) internal pure returns (uint256) { + // If n=1, no proof. Otherwise, each tree level can contribute 1 node in the proof path. + // A simplistic approach: log2(n) <= 256 bits for typical usage, but we do this in-line: + uint256 count = 0; + while (n > 1) { + n = (n + 1) / 2; // integer division round up + count++; + } + return count; + } +}