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/erc721m/clones/ERC721ACloneable.sol b/contracts/nft/erc721m/clones/ERC721ACloneable.sol new file mode 100644 index 00000000..401fde71 --- /dev/null +++ b/contracts/nft/erc721m/clones/ERC721ACloneable.sol @@ -0,0 +1,1292 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.3.0 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import {IERC721A} from "erc721a/contracts/IERC721A.sol"; + +import {Initializable} from "solady/src/utils/Initializable.sol"; + +/** + * @dev Interface of ERC721 token receiver. + */ +interface ERC721A__IERC721Receiver { + function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) + external + returns (bytes4); +} + +/** + * @title ERC721ACloneable + * + * @dev Implementation of the [ERC721](https://eips.ethereum.org/EIPS/eip-721) + * Non-Fungible Token Standard, including the Metadata extension. + * Optimized for lower gas during batch mints. + * + * Token IDs are minted in sequential order (e.g. 0, 1, 2, 3, ...) + * starting from `_startTokenId()`. + * + * The `_sequentialUpTo()` function can be overriden to enable spot mints + * (i.e. non-consecutive mints) for `tokenId`s greater than `_sequentialUpTo()`. + * + * Assumptions: + * + * - An owner cannot have more than 2**64 - 1 (max value of uint64) of supply. + * - The maximum token ID cannot exceed 2**256 - 1 (max value of uint256). + */ +contract ERC721ACloneable is IERC721A, Initializable { + // Bypass for a `--via-ir` bug (https://github.com/chiru-labs/ERC721A/pull/364). + struct TokenApprovalRef { + address value; + } + + // ============================================================= + // CONSTANTS + // ============================================================= + + // Mask of an entry in packed address data. + uint256 private constant _BITMASK_ADDRESS_DATA_ENTRY = (1 << 64) - 1; + + // The bit position of `numberMinted` in packed address data. + uint256 private constant _BITPOS_NUMBER_MINTED = 64; + + // The bit position of `numberBurned` in packed address data. + uint256 private constant _BITPOS_NUMBER_BURNED = 128; + + // The bit position of `aux` in packed address data. + uint256 private constant _BITPOS_AUX = 192; + + // Mask of all 256 bits in packed address data except the 64 bits for `aux`. + uint256 private constant _BITMASK_AUX_COMPLEMENT = (1 << 192) - 1; + + // The bit position of `startTimestamp` in packed ownership. + uint256 private constant _BITPOS_START_TIMESTAMP = 160; + + // The bit mask of the `burned` bit in packed ownership. + uint256 private constant _BITMASK_BURNED = 1 << 224; + + // The bit position of the `nextInitialized` bit in packed ownership. + uint256 private constant _BITPOS_NEXT_INITIALIZED = 225; + + // The bit mask of the `nextInitialized` bit in packed ownership. + uint256 private constant _BITMASK_NEXT_INITIALIZED = 1 << 225; + + // The bit position of `extraData` in packed ownership. + uint256 private constant _BITPOS_EXTRA_DATA = 232; + + // Mask of all 256 bits in a packed ownership except the 24 bits for `extraData`. + uint256 private constant _BITMASK_EXTRA_DATA_COMPLEMENT = (1 << 232) - 1; + + // The mask of the lower 160 bits for addresses. + uint256 private constant _BITMASK_ADDRESS = (1 << 160) - 1; + + // The maximum `quantity` that can be minted with {_mintERC2309}. + // This limit is to prevent overflows on the address data entries. + // For a limit of 5000, a total of 3.689e15 calls to {_mintERC2309} + // is required to cause an overflow, which is unrealistic. + uint256 private constant _MAX_MINT_ERC2309_QUANTITY_LIMIT = 5000; + + // The `Transfer` event signature is given by: + // `keccak256(bytes("Transfer(address,address,uint256)"))`. + bytes32 private constant _TRANSFER_EVENT_SIGNATURE = + 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef; + + // ============================================================= + // STORAGE + // ============================================================= + + // The next token ID to be minted. + uint256 private _currentIndex; + + // The number of tokens burned. + uint256 private _burnCounter; + + // Token name + string private _name; + + // Token symbol + string private _symbol; + + // Mapping from token ID to ownership details + // An empty struct value does not necessarily mean the token is unowned. + // See {_packedOwnershipOf} implementation for details. + // + // Bits Layout: + // - [0..159] `addr` + // - [160..223] `startTimestamp` + // - [224] `burned` + // - [225] `nextInitialized` + // - [232..255] `extraData` + mapping(uint256 => uint256) private _packedOwnerships; + + // Mapping owner address to address data. + // + // Bits Layout: + // - [0..63] `balance` + // - [64..127] `numberMinted` + // - [128..191] `numberBurned` + // - [192..255] `aux` + mapping(address => uint256) private _packedAddressData; + + // Mapping from token ID to approved address. + mapping(uint256 => TokenApprovalRef) private _tokenApprovals; + + // Mapping from owner to operator approvals + mapping(address => mapping(address => bool)) private _operatorApprovals; + + // The amount of tokens minted above `_sequentialUpTo()`. + // We call these spot mints (i.e. non-sequential mints). + uint256 private _spotMinted; + + // ============================================================= + // INITIALIZER + // ============================================================= + + function __ERC721ACloneable__init(string memory name_, string memory symbol_) internal onlyInitializing { + _name = name_; + _symbol = symbol_; + _currentIndex = _startTokenId(); + + if (_sequentialUpTo() < _startTokenId()) { + _revert(SequentialUpToTooSmall.selector); + } + } + + // ============================================================= + // TOKEN COUNTING OPERATIONS + // ============================================================= + + /** + * @dev Returns the starting token ID for sequential mints. + * + * Override this function to change the starting token ID for sequential mints. + * + * Note: The value returned must never change after any tokens have been minted. + */ + function _startTokenId() internal view virtual returns (uint256) { + return 0; + } + + /** + * @dev Returns the maximum token ID (inclusive) for sequential mints. + * + * Override this function to return a value less than 2**256 - 1, + * but greater than `_startTokenId()`, to enable spot (non-sequential) mints. + * + * Note: The value returned must never change after any tokens have been minted. + */ + function _sequentialUpTo() internal view virtual returns (uint256) { + return type(uint256).max; + } + + /** + * @dev Returns the next token ID to be minted. + */ + function _nextTokenId() internal view virtual returns (uint256) { + return _currentIndex; + } + + /** + * @dev Returns the total number of tokens in existence. + * Burned tokens will reduce the count. + * To get the total number of tokens minted, please see {_totalMinted}. + */ + function totalSupply() public view virtual override returns (uint256 result) { + // Counter underflow is impossible as `_burnCounter` cannot be incremented + // more than `_currentIndex + _spotMinted - _startTokenId()` times. + unchecked { + // With spot minting, the intermediate `result` can be temporarily negative, + // and the computation must be unchecked. + result = _currentIndex - _burnCounter - _startTokenId(); + if (_sequentialUpTo() != type(uint256).max) result += _spotMinted; + } + } + + /** + * @dev Returns the total amount of tokens minted in the contract. + */ + function _totalMinted() internal view virtual returns (uint256 result) { + // Counter underflow is impossible as `_currentIndex` does not decrement, + // and it is initialized to `_startTokenId()`. + unchecked { + result = _currentIndex - _startTokenId(); + if (_sequentialUpTo() != type(uint256).max) result += _spotMinted; + } + } + + /** + * @dev Returns the total number of tokens burned. + */ + function _totalBurned() internal view virtual returns (uint256) { + return _burnCounter; + } + + /** + * @dev Returns the total number of tokens that are spot-minted. + */ + function _totalSpotMinted() internal view virtual returns (uint256) { + return _spotMinted; + } + + // ============================================================= + // ADDRESS DATA OPERATIONS + // ============================================================= + + /** + * @dev Returns the number of tokens in `owner`'s account. + */ + function balanceOf(address owner) public view virtual override returns (uint256) { + if (owner == address(0)) _revert(BalanceQueryForZeroAddress.selector); + return _packedAddressData[owner] & _BITMASK_ADDRESS_DATA_ENTRY; + } + + /** + * Returns the number of tokens minted by `owner`. + */ + function _numberMinted(address owner) internal view returns (uint256) { + return (_packedAddressData[owner] >> _BITPOS_NUMBER_MINTED) & _BITMASK_ADDRESS_DATA_ENTRY; + } + + /** + * Returns the number of tokens burned by or on behalf of `owner`. + */ + function _numberBurned(address owner) internal view returns (uint256) { + return (_packedAddressData[owner] >> _BITPOS_NUMBER_BURNED) & _BITMASK_ADDRESS_DATA_ENTRY; + } + + /** + * Returns the auxiliary data for `owner`. (e.g. number of whitelist mint slots used). + */ + function _getAux(address owner) internal view returns (uint64) { + return uint64(_packedAddressData[owner] >> _BITPOS_AUX); + } + + /** + * Sets the auxiliary data for `owner`. (e.g. number of whitelist mint slots used). + * If there are multiple variables, please pack them into a uint64. + */ + function _setAux(address owner, uint64 aux) internal virtual { + uint256 packed = _packedAddressData[owner]; + uint256 auxCasted; + // Cast `aux` with assembly to avoid redundant masking. + assembly { + auxCasted := aux + } + packed = (packed & _BITMASK_AUX_COMPLEMENT) | (auxCasted << _BITPOS_AUX); + _packedAddressData[owner] = packed; + } + + // ============================================================= + // IERC165 + // ============================================================= + + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * [EIP section](https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified) + * to learn more about how these ids are created. + * + * This function call must use less than 30000 gas. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + // The interface IDs are constants representing the first 4 bytes + // of the XOR of all function selectors in the interface. + // See: [ERC165](https://eips.ethereum.org/EIPS/eip-165) + // (e.g. `bytes4(i.functionA.selector ^ i.functionB.selector ^ ...)`) + return interfaceId == 0x01ffc9a7 // ERC165 interface ID for ERC165. + || interfaceId == 0x80ac58cd // ERC165 interface ID for ERC721. + || interfaceId == 0x5b5e139f; // ERC165 interface ID for ERC721Metadata. + } + + // ============================================================= + // IERC721Metadata + // ============================================================= + + /** + * @dev Returns the token collection name. + */ + function name() public view virtual override returns (string memory) { + return _name; + } + + /** + * @dev Returns the token collection symbol. + */ + function symbol() public view virtual override returns (string memory) { + return _symbol; + } + + /** + * @dev Returns the Uniform Resource Identifier (URI) for `tokenId` token. + */ + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + if (!_exists(tokenId)) _revert(URIQueryForNonexistentToken.selector); + + string memory baseURI = _baseURI(); + return bytes(baseURI).length != 0 ? string(abi.encodePacked(baseURI, _toString(tokenId))) : ""; + } + + /** + * @dev Base URI for computing {tokenURI}. If set, the resulting URI for each + * token will be the concatenation of the `baseURI` and the `tokenId`. Empty + * by default, it can be overridden in child contracts. + */ + function _baseURI() internal view virtual returns (string memory) { + return ""; + } + + // ============================================================= + // OWNERSHIPS OPERATIONS + // ============================================================= + + /** + * @dev Returns the owner of the `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function ownerOf(uint256 tokenId) public view virtual override returns (address) { + return address(uint160(_packedOwnershipOf(tokenId))); + } + + /** + * @dev Gas spent here starts off proportional to the maximum mint batch size. + * It gradually moves to O(1) as tokens get transferred around over time. + */ + function _ownershipOf(uint256 tokenId) internal view virtual returns (TokenOwnership memory) { + return _unpackedOwnership(_packedOwnershipOf(tokenId)); + } + + /** + * @dev Returns the unpacked `TokenOwnership` struct at `index`. + */ + function _ownershipAt(uint256 index) internal view virtual returns (TokenOwnership memory) { + return _unpackedOwnership(_packedOwnerships[index]); + } + + /** + * @dev Returns whether the ownership slot at `index` is initialized. + * An uninitialized slot does not necessarily mean that the slot has no owner. + */ + function _ownershipIsInitialized(uint256 index) internal view virtual returns (bool) { + return _packedOwnerships[index] != 0; + } + + /** + * @dev Initializes the ownership slot minted at `index` for efficiency purposes. + */ + function _initializeOwnershipAt(uint256 index) internal virtual { + if (_packedOwnerships[index] == 0) { + _packedOwnerships[index] = _packedOwnershipOf(index); + } + } + + /** + * @dev Returns the packed ownership data of `tokenId`. + */ + function _packedOwnershipOf(uint256 tokenId) private view returns (uint256 packed) { + if (_startTokenId() <= tokenId) { + packed = _packedOwnerships[tokenId]; + + if (tokenId > _sequentialUpTo()) { + if (_packedOwnershipExists(packed)) return packed; + _revert(OwnerQueryForNonexistentToken.selector); + } + + // If the data at the starting slot does not exist, start the scan. + if (packed == 0) { + if (tokenId >= _currentIndex) { + _revert(OwnerQueryForNonexistentToken.selector); + } + // Invariant: + // There will always be an initialized ownership slot + // (i.e. `ownership.addr != address(0) && ownership.burned == false`) + // before an unintialized ownership slot + // (i.e. `ownership.addr == address(0) && ownership.burned == false`) + // Hence, `tokenId` will not underflow. + // + // We can directly compare the packed value. + // If the address is zero, packed will be zero. + for (;;) { + unchecked { + packed = _packedOwnerships[--tokenId]; + } + if (packed == 0) continue; + if (packed & _BITMASK_BURNED == 0) return packed; + // Otherwise, the token is burned, and we must revert. + // This handles the case of batch burned tokens, where only the burned bit + // of the starting slot is set, and remaining slots are left uninitialized. + _revert(OwnerQueryForNonexistentToken.selector); + } + } + // Otherwise, the data exists and we can skip the scan. + // This is possible because we have already achieved the target condition. + // This saves 2143 gas on transfers of initialized tokens. + // If the token is not burned, return `packed`. Otherwise, revert. + if (packed & _BITMASK_BURNED == 0) return packed; + } + _revert(OwnerQueryForNonexistentToken.selector); + } + + /** + * @dev Returns the unpacked `TokenOwnership` struct from `packed`. + */ + function _unpackedOwnership(uint256 packed) private pure returns (TokenOwnership memory ownership) { + ownership.addr = address(uint160(packed)); + ownership.startTimestamp = uint64(packed >> _BITPOS_START_TIMESTAMP); + ownership.burned = packed & _BITMASK_BURNED != 0; + ownership.extraData = uint24(packed >> _BITPOS_EXTRA_DATA); + } + + /** + * @dev Packs ownership data into a single uint256. + */ + function _packOwnershipData(address owner, uint256 flags) private view returns (uint256 result) { + assembly { + // Mask `owner` to the lower 160 bits, in case the upper bits somehow aren't clean. + owner := and(owner, _BITMASK_ADDRESS) + // `owner | (block.timestamp << _BITPOS_START_TIMESTAMP) | flags`. + result := or(owner, or(shl(_BITPOS_START_TIMESTAMP, timestamp()), flags)) + } + } + + /** + * @dev Returns the `nextInitialized` flag set if `quantity` equals 1. + */ + function _nextInitializedFlag(uint256 quantity) private pure returns (uint256 result) { + // For branchless setting of the `nextInitialized` flag. + assembly { + // `(quantity == 1) << _BITPOS_NEXT_INITIALIZED`. + result := shl(_BITPOS_NEXT_INITIALIZED, eq(quantity, 1)) + } + } + + // ============================================================= + // APPROVAL OPERATIONS + // ============================================================= + + /** + * @dev Gives permission to `to` to transfer `tokenId` token to another account. See {ERC721A-_approve}. + * + * Requirements: + * + * - The caller must own the token or be an approved operator. + */ + function approve(address to, uint256 tokenId) public payable virtual override { + _approve(to, tokenId, true); + } + + /** + * @dev Returns the account approved for `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function getApproved(uint256 tokenId) public view virtual override returns (address) { + if (!_exists(tokenId)) { + _revert(ApprovalQueryForNonexistentToken.selector); + } + + return _tokenApprovals[tokenId].value; + } + + /** + * @dev Approve or remove `operator` as an operator for the caller. + * Operators can call {transferFrom} or {safeTransferFrom} + * for any token owned by the caller. + * + * Requirements: + * + * - The `operator` cannot be the caller. + * + * Emits an {ApprovalForAll} event. + */ + function setApprovalForAll(address operator, bool approved) public virtual override { + _operatorApprovals[_msgSenderERC721A()][operator] = approved; + emit ApprovalForAll(_msgSenderERC721A(), operator, approved); + } + + /** + * @dev Returns if the `operator` is allowed to manage all of the assets of `owner`. + * + * See {setApprovalForAll}. + */ + function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) { + return _operatorApprovals[owner][operator]; + } + + /** + * @dev Returns whether `tokenId` exists. + * + * Tokens can be managed by their owner or approved accounts via {approve} or {setApprovalForAll}. + * + * Tokens start existing when they are minted. See {_mint}. + */ + function _exists(uint256 tokenId) internal view virtual returns (bool result) { + if (_startTokenId() <= tokenId) { + if (tokenId > _sequentialUpTo()) { + return _packedOwnershipExists(_packedOwnerships[tokenId]); + } + + if (tokenId < _currentIndex) { + uint256 packed; + while ((packed = _packedOwnerships[tokenId]) == 0) --tokenId; + result = packed & _BITMASK_BURNED == 0; + } + } + } + + /** + * @dev Returns whether `packed` represents a token that exists. + */ + function _packedOwnershipExists(uint256 packed) private pure returns (bool result) { + assembly { + // The following is equivalent to `owner != address(0) && burned == false`. + // Symbolically tested. + result := gt(and(packed, _BITMASK_ADDRESS), and(packed, _BITMASK_BURNED)) + } + } + + /** + * @dev Returns whether `msgSender` is equal to `approvedAddress` or `owner`. + */ + function _isSenderApprovedOrOwner(address approvedAddress, address owner, address msgSender) + private + pure + returns (bool result) + { + assembly { + // Mask `owner` to the lower 160 bits, in case the upper bits somehow aren't clean. + owner := and(owner, _BITMASK_ADDRESS) + // Mask `msgSender` to the lower 160 bits, in case the upper bits somehow aren't clean. + msgSender := and(msgSender, _BITMASK_ADDRESS) + // `msgSender == owner || msgSender == approvedAddress`. + result := or(eq(msgSender, owner), eq(msgSender, approvedAddress)) + } + } + + /** + * @dev Returns the storage slot and value for the approved address of `tokenId`. + */ + function _getApprovedSlotAndAddress(uint256 tokenId) + private + view + returns (uint256 approvedAddressSlot, address approvedAddress) + { + TokenApprovalRef storage tokenApproval = _tokenApprovals[tokenId]; + // The following is equivalent to `approvedAddress = _tokenApprovals[tokenId].value`. + assembly { + approvedAddressSlot := tokenApproval.slot + approvedAddress := sload(approvedAddressSlot) + } + } + + // ============================================================= + // TRANSFER OPERATIONS + // ============================================================= + + /** + * @dev Transfers `tokenId` from `from` to `to`. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token + * by either {approve} or {setApprovalForAll}. + * + * Emits a {Transfer} event. + */ + function transferFrom(address from, address to, uint256 tokenId) public payable virtual override { + uint256 prevOwnershipPacked = _packedOwnershipOf(tokenId); + + // Mask `from` to the lower 160 bits, in case the upper bits somehow aren't clean. + from = address(uint160(uint256(uint160(from)) & _BITMASK_ADDRESS)); + + if (address(uint160(prevOwnershipPacked)) != from) { + _revert(TransferFromIncorrectOwner.selector); + } + + (uint256 approvedAddressSlot, address approvedAddress) = _getApprovedSlotAndAddress(tokenId); + + // The nested ifs save around 20+ gas over a compound boolean condition. + if (!_isSenderApprovedOrOwner(approvedAddress, from, _msgSenderERC721A())) { + if (!isApprovedForAll(from, _msgSenderERC721A())) { + _revert(TransferCallerNotOwnerNorApproved.selector); + } + } + + _beforeTokenTransfers(from, to, tokenId, 1); + + // Clear approvals from the previous owner. + assembly { + if approvedAddress { + // This is equivalent to `delete _tokenApprovals[tokenId]`. + sstore(approvedAddressSlot, 0) + } + } + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as `tokenId` would have to be 2**256. + unchecked { + // We can directly increment and decrement the balances. + --_packedAddressData[from]; // Updates: `balance -= 1`. + ++_packedAddressData[to]; // Updates: `balance += 1`. + + // Updates: + // - `address` to the next owner. + // - `startTimestamp` to the timestamp of transfering. + // - `burned` to `false`. + // - `nextInitialized` to `true`. + _packedOwnerships[tokenId] = + _packOwnershipData(to, _BITMASK_NEXT_INITIALIZED | _nextExtraData(from, to, prevOwnershipPacked)); + + // If the next slot may not have been initialized (i.e. `nextInitialized == false`) . + if (prevOwnershipPacked & _BITMASK_NEXT_INITIALIZED == 0) { + uint256 nextTokenId = tokenId + 1; + // If the next slot's address is zero and not burned (i.e. packed value is zero). + if (_packedOwnerships[nextTokenId] == 0) { + // If the next slot is within bounds. + if (nextTokenId != _currentIndex) { + // Initialize the next slot to maintain correctness for `ownerOf(tokenId + 1)`. + _packedOwnerships[nextTokenId] = prevOwnershipPacked; + } + } + } + } + + // Mask `to` to the lower 160 bits, in case the upper bits somehow aren't clean. + uint256 toMasked = uint256(uint160(to)) & _BITMASK_ADDRESS; + assembly { + // Emit the `Transfer` event. + log4( + 0, // Start of data (0, since no data). + 0, // End of data (0, since no data). + _TRANSFER_EVENT_SIGNATURE, // Signature. + from, // `from`. + toMasked, // `to`. + tokenId // `tokenId`. + ) + } + if (toMasked == 0) _revert(TransferToZeroAddress.selector); + + _afterTokenTransfers(from, to, tokenId, 1); + } + + /** + * @dev Equivalent to `safeTransferFrom(from, to, tokenId, '')`. + */ + function safeTransferFrom(address from, address to, uint256 tokenId) public payable virtual override { + safeTransferFrom(from, to, tokenId, ""); + } + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token + * by either {approve} or {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement + * {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) + public + payable + virtual + override + { + transferFrom(from, to, tokenId); + if (to.code.length != 0) { + if (!_checkContractOnERC721Received(from, to, tokenId, _data)) { + _revert(TransferToNonERC721ReceiverImplementer.selector); + } + } + } + + /** + * @dev Hook that is called before a set of serially-ordered token IDs + * are about to be transferred. This includes minting. + * And also called before burning one token. + * + * `startTokenId` - the first token ID to be transferred. + * `quantity` - the amount to be transferred. + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` will be + * transferred to `to`. + * - When `from` is zero, `tokenId` will be minted for `to`. + * - When `to` is zero, `tokenId` will be burned by `from`. + * - `from` and `to` are never both zero. + */ + function _beforeTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual {} + + /** + * @dev Hook that is called after a set of serially-ordered token IDs + * have been transferred. This includes minting. + * And also called after one token has been burned. + * + * `startTokenId` - the first token ID to be transferred. + * `quantity` - the amount to be transferred. + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` has been + * transferred to `to`. + * - When `from` is zero, `tokenId` has been minted for `to`. + * - When `to` is zero, `tokenId` has been burned by `from`. + * - `from` and `to` are never both zero. + */ + function _afterTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual {} + + /** + * @dev Private function to invoke {IERC721Receiver-onERC721Received} on a target contract. + * + * `from` - Previous owner of the given token ID. + * `to` - Target address that will receive the token. + * `tokenId` - Token ID to be transferred. + * `_data` - Optional data to send along with the call. + * + * Returns whether the call correctly returned the expected magic value. + */ + function _checkContractOnERC721Received(address from, address to, uint256 tokenId, bytes memory _data) + private + returns (bool) + { + try ERC721A__IERC721Receiver(to).onERC721Received(_msgSenderERC721A(), from, tokenId, _data) returns ( + bytes4 retval + ) { + return retval == ERC721A__IERC721Receiver(to).onERC721Received.selector; + } catch (bytes memory reason) { + if (reason.length == 0) { + _revert(TransferToNonERC721ReceiverImplementer.selector); + } + assembly { + revert(add(32, reason), mload(reason)) + } + } + } + + // ============================================================= + // MINT OPERATIONS + // ============================================================= + + /** + * @dev Mints `quantity` tokens and transfers them to `to`. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `quantity` must be greater than 0. + * + * Emits a {Transfer} event for each mint. + */ + function _mint(address to, uint256 quantity) internal virtual { + uint256 startTokenId = _currentIndex; + if (quantity == 0) _revert(MintZeroQuantity.selector); + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are incredibly unrealistic. + // `balance` and `numberMinted` have a maximum limit of 2**64. + // `tokenId` has a maximum limit of 2**256. + unchecked { + // Updates: + // - `address` to the owner. + // - `startTimestamp` to the timestamp of minting. + // - `burned` to `false`. + // - `nextInitialized` to `quantity == 1`. + _packedOwnerships[startTokenId] = + _packOwnershipData(to, _nextInitializedFlag(quantity) | _nextExtraData(address(0), to, 0)); + + // Updates: + // - `balance += quantity`. + // - `numberMinted += quantity`. + // + // We can directly add to the `balance` and `numberMinted`. + _packedAddressData[to] += quantity * ((1 << _BITPOS_NUMBER_MINTED) | 1); + + // Mask `to` to the lower 160 bits, in case the upper bits somehow aren't clean. + uint256 toMasked = uint256(uint160(to)) & _BITMASK_ADDRESS; + + if (toMasked == 0) _revert(MintToZeroAddress.selector); + + uint256 end = startTokenId + quantity; + uint256 tokenId = startTokenId; + + if (end - 1 > _sequentialUpTo()) { + _revert(SequentialMintExceedsLimit.selector); + } + + do { + assembly { + // Emit the `Transfer` event. + log4( + 0, // Start of data (0, since no data). + 0, // End of data (0, since no data). + _TRANSFER_EVENT_SIGNATURE, // Signature. + 0, // `address(0)`. + toMasked, // `to`. + tokenId // `tokenId`. + ) + } + // The `!=` check ensures that large values of `quantity` + // that overflows uint256 will make the loop run out of gas. + } while (++tokenId != end); + + _currentIndex = end; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + + /** + * @dev Mints `quantity` tokens and transfers them to `to`. + * + * This function is intended for efficient minting only during contract creation. + * + * It emits only one {ConsecutiveTransfer} as defined in + * [ERC2309](https://eips.ethereum.org/EIPS/eip-2309), + * instead of a sequence of {Transfer} event(s). + * + * Calling this function outside of contract creation WILL make your contract + * non-compliant with the ERC721 standard. + * For full ERC721 compliance, substituting ERC721 {Transfer} event(s) with the ERC2309 + * {ConsecutiveTransfer} event is only permissible during contract creation. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `quantity` must be greater than 0. + * + * Emits a {ConsecutiveTransfer} event. + */ + function _mintERC2309(address to, uint256 quantity) internal virtual { + uint256 startTokenId = _currentIndex; + if (to == address(0)) _revert(MintToZeroAddress.selector); + if (quantity == 0) _revert(MintZeroQuantity.selector); + if (quantity > _MAX_MINT_ERC2309_QUANTITY_LIMIT) { + _revert(MintERC2309QuantityExceedsLimit.selector); + } + + _beforeTokenTransfers(address(0), to, startTokenId, quantity); + + // Overflows are unrealistic due to the above check for `quantity` to be below the limit. + unchecked { + // Updates: + // - `balance += quantity`. + // - `numberMinted += quantity`. + // + // We can directly add to the `balance` and `numberMinted`. + _packedAddressData[to] += quantity * ((1 << _BITPOS_NUMBER_MINTED) | 1); + + // Updates: + // - `address` to the owner. + // - `startTimestamp` to the timestamp of minting. + // - `burned` to `false`. + // - `nextInitialized` to `quantity == 1`. + _packedOwnerships[startTokenId] = + _packOwnershipData(to, _nextInitializedFlag(quantity) | _nextExtraData(address(0), to, 0)); + + if (startTokenId + quantity - 1 > _sequentialUpTo()) { + _revert(SequentialMintExceedsLimit.selector); + } + + emit ConsecutiveTransfer(startTokenId, startTokenId + quantity - 1, address(0), to); + + _currentIndex = startTokenId + quantity; + } + _afterTokenTransfers(address(0), to, startTokenId, quantity); + } + + /** + * @dev Safely mints `quantity` tokens and transfers them to `to`. + * + * Requirements: + * + * - If `to` refers to a smart contract, it must implement + * {IERC721Receiver-onERC721Received}, which is called for each safe transfer. + * - `quantity` must be greater than 0. + * + * See {_mint}. + * + * Emits a {Transfer} event for each mint. + */ + function _safeMint(address to, uint256 quantity, bytes memory _data) internal virtual { + _mint(to, quantity); + + unchecked { + if (to.code.length != 0) { + uint256 end = _currentIndex; + uint256 index = end - quantity; + do { + if (!_checkContractOnERC721Received(address(0), to, index++, _data)) { + _revert(TransferToNonERC721ReceiverImplementer.selector); + } + } while (index < end); + // This prevents reentrancy to `_safeMint`. + // It does not prevent reentrancy to `_safeMintSpot`. + if (_currentIndex != end) revert(); + } + } + } + + /** + * @dev Equivalent to `_safeMint(to, quantity, '')`. + */ + function _safeMint(address to, uint256 quantity) internal virtual { + _safeMint(to, quantity, ""); + } + + /** + * @dev Mints a single token at `tokenId`. + * + * Note: A spot-minted `tokenId` that has been burned can be re-minted again. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `tokenId` must be greater than `_sequentialUpTo()`. + * - `tokenId` must not exist. + * + * Emits a {Transfer} event for each mint. + */ + function _mintSpot(address to, uint256 tokenId) internal virtual { + if (tokenId <= _sequentialUpTo()) { + _revert(SpotMintTokenIdTooSmall.selector); + } + uint256 prevOwnershipPacked = _packedOwnerships[tokenId]; + if (_packedOwnershipExists(prevOwnershipPacked)) { + _revert(TokenAlreadyExists.selector); + } + + _beforeTokenTransfers(address(0), to, tokenId, 1); + + // Overflows are incredibly unrealistic. + // The `numberMinted` for `to` is incremented by 1, and has a max limit of 2**64 - 1. + // `_spotMinted` is incremented by 1, and has a max limit of 2**256 - 1. + unchecked { + // Updates: + // - `address` to the owner. + // - `startTimestamp` to the timestamp of minting. + // - `burned` to `false`. + // - `nextInitialized` to `true` (as `quantity == 1`). + _packedOwnerships[tokenId] = + _packOwnershipData(to, _nextInitializedFlag(1) | _nextExtraData(address(0), to, prevOwnershipPacked)); + + // Updates: + // - `balance += 1`. + // - `numberMinted += 1`. + // + // We can directly add to the `balance` and `numberMinted`. + _packedAddressData[to] += (1 << _BITPOS_NUMBER_MINTED) | 1; + + // Mask `to` to the lower 160 bits, in case the upper bits somehow aren't clean. + uint256 toMasked = uint256(uint160(to)) & _BITMASK_ADDRESS; + + if (toMasked == 0) _revert(MintToZeroAddress.selector); + + assembly { + // Emit the `Transfer` event. + log4( + 0, // Start of data (0, since no data). + 0, // End of data (0, since no data). + _TRANSFER_EVENT_SIGNATURE, // Signature. + 0, // `address(0)`. + toMasked, // `to`. + tokenId // `tokenId`. + ) + } + + ++_spotMinted; + } + + _afterTokenTransfers(address(0), to, tokenId, 1); + } + + /** + * @dev Safely mints a single token at `tokenId`. + * + * Note: A spot-minted `tokenId` that has been burned can be re-minted again. + * + * Requirements: + * + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}. + * - `tokenId` must be greater than `_sequentialUpTo()`. + * - `tokenId` must not exist. + * + * See {_mintSpot}. + * + * Emits a {Transfer} event. + */ + function _safeMintSpot(address to, uint256 tokenId, bytes memory _data) internal virtual { + _mintSpot(to, tokenId); + + unchecked { + if (to.code.length != 0) { + uint256 currentSpotMinted = _spotMinted; + if (!_checkContractOnERC721Received(address(0), to, tokenId, _data)) { + _revert(TransferToNonERC721ReceiverImplementer.selector); + } + // This prevents reentrancy to `_safeMintSpot`. + // It does not prevent reentrancy to `_safeMint`. + if (_spotMinted != currentSpotMinted) revert(); + } + } + } + + /** + * @dev Equivalent to `_safeMintSpot(to, tokenId, '')`. + */ + function _safeMintSpot(address to, uint256 tokenId) internal virtual { + _safeMintSpot(to, tokenId, ""); + } + + // ============================================================= + // APPROVAL OPERATIONS + // ============================================================= + + /** + * @dev Equivalent to `_approve(to, tokenId, false)`. + */ + function _approve(address to, uint256 tokenId) internal virtual { + _approve(to, tokenId, false); + } + + /** + * @dev Gives permission to `to` to transfer `tokenId` token to another account. + * The approval is cleared when the token is transferred. + * + * Only a single account can be approved at a time, so approving the + * zero address clears previous approvals. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits an {Approval} event. + */ + function _approve(address to, uint256 tokenId, bool approvalCheck) internal virtual { + address owner = ownerOf(tokenId); + + if (approvalCheck && _msgSenderERC721A() != owner) { + if (!isApprovedForAll(owner, _msgSenderERC721A())) { + _revert(ApprovalCallerNotOwnerNorApproved.selector); + } + } + + _tokenApprovals[tokenId].value = to; + emit Approval(owner, to, tokenId); + } + + // ============================================================= + // BURN OPERATIONS + // ============================================================= + + /** + * @dev Equivalent to `_burn(tokenId, false)`. + */ + function _burn(uint256 tokenId) internal virtual { + _burn(tokenId, false); + } + + /** + * @dev Destroys `tokenId`. + * The approval is cleared when the token is burned. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits a {Transfer} event. + */ + function _burn(uint256 tokenId, bool approvalCheck) internal virtual { + uint256 prevOwnershipPacked = _packedOwnershipOf(tokenId); + + address from = address(uint160(prevOwnershipPacked)); + + (uint256 approvedAddressSlot, address approvedAddress) = _getApprovedSlotAndAddress(tokenId); + + if (approvalCheck) { + // The nested ifs save around 20+ gas over a compound boolean condition. + if (!_isSenderApprovedOrOwner(approvedAddress, from, _msgSenderERC721A())) { + if (!isApprovedForAll(from, _msgSenderERC721A())) { + _revert(TransferCallerNotOwnerNorApproved.selector); + } + } + } + + _beforeTokenTransfers(from, address(0), tokenId, 1); + + // Clear approvals from the previous owner. + assembly { + if approvedAddress { + // This is equivalent to `delete _tokenApprovals[tokenId]`. + sstore(approvedAddressSlot, 0) + } + } + + // Underflow of the sender's balance is impossible because we check for + // ownership above and the recipient's balance can't realistically overflow. + // Counter overflow is incredibly unrealistic as `tokenId` would have to be 2**256. + unchecked { + // Updates: + // - `balance -= 1`. + // - `numberBurned += 1`. + // + // We can directly decrement the balance, and increment the number burned. + // This is equivalent to `packed -= 1; packed += 1 << _BITPOS_NUMBER_BURNED;`. + _packedAddressData[from] += (1 << _BITPOS_NUMBER_BURNED) - 1; + + // Updates: + // - `address` to the last owner. + // - `startTimestamp` to the timestamp of burning. + // - `burned` to `true`. + // - `nextInitialized` to `true`. + _packedOwnerships[tokenId] = _packOwnershipData( + from, + (_BITMASK_BURNED | _BITMASK_NEXT_INITIALIZED) | _nextExtraData(from, address(0), prevOwnershipPacked) + ); + + // If the next slot may not have been initialized (i.e. `nextInitialized == false`) . + if (prevOwnershipPacked & _BITMASK_NEXT_INITIALIZED == 0) { + uint256 nextTokenId = tokenId + 1; + // If the next slot's address is zero and not burned (i.e. packed value is zero). + if (_packedOwnerships[nextTokenId] == 0) { + // If the next slot is within bounds. + if (nextTokenId != _currentIndex) { + // Initialize the next slot to maintain correctness for `ownerOf(tokenId + 1)`. + _packedOwnerships[nextTokenId] = prevOwnershipPacked; + } + } + } + } + + emit Transfer(from, address(0), tokenId); + _afterTokenTransfers(from, address(0), tokenId, 1); + + // Overflow not possible, as `_burnCounter` cannot be exceed `_currentIndex + _spotMinted` times. + unchecked { + _burnCounter++; + } + } + + // ============================================================= + // EXTRA DATA OPERATIONS + // ============================================================= + + /** + * @dev Directly sets the extra data for the ownership data `index`. + */ + function _setExtraDataAt(uint256 index, uint24 extraData) internal virtual { + uint256 packed = _packedOwnerships[index]; + if (packed == 0) _revert(OwnershipNotInitializedForExtraData.selector); + uint256 extraDataCasted; + // Cast `extraData` with assembly to avoid redundant masking. + assembly { + extraDataCasted := extraData + } + packed = (packed & _BITMASK_EXTRA_DATA_COMPLEMENT) | (extraDataCasted << _BITPOS_EXTRA_DATA); + _packedOwnerships[index] = packed; + } + + /** + * @dev Called during each token transfer to set the 24bit `extraData` field. + * Intended to be overridden by the cosumer contract. + * + * `previousExtraData` - the value of `extraData` before transfer. + * + * Calling conditions: + * + * - When `from` and `to` are both non-zero, `from`'s `tokenId` will be + * transferred to `to`. + * - When `from` is zero, `tokenId` will be minted for `to`. + * - When `to` is zero, `tokenId` will be burned by `from`. + * - `from` and `to` are never both zero. + */ + function _extraData(address from, address to, uint24 previousExtraData) internal view virtual returns (uint24) {} + + /** + * @dev Returns the next extra data for the packed ownership data. + * The returned result is shifted into position. + */ + function _nextExtraData(address from, address to, uint256 prevOwnershipPacked) private view returns (uint256) { + uint24 extraData = uint24(prevOwnershipPacked >> _BITPOS_EXTRA_DATA); + return uint256(_extraData(from, to, extraData)) << _BITPOS_EXTRA_DATA; + } + + // ============================================================= + // OTHER OPERATIONS + // ============================================================= + + /** + * @dev Returns the message sender (defaults to `msg.sender`). + * + * If you are writing GSN compatible contracts, you need to override this function. + */ + function _msgSenderERC721A() internal view virtual returns (address) { + return msg.sender; + } + + /** + * @dev Converts a uint256 to its ASCII string decimal representation. + */ + function _toString(uint256 value) internal pure virtual returns (string memory str) { + assembly { + // The maximum value of a uint256 contains 78 digits (1 byte per digit), but + // we allocate 0xa0 bytes to keep the free memory pointer 32-byte word aligned. + // We will need 1 word for the trailing zeros padding, 1 word for the length, + // and 3 words for a maximum of 78 digits. Total: 5 * 0x20 = 0xa0. + let m := add(mload(0x40), 0xa0) + // Update the free memory pointer to allocate. + mstore(0x40, m) + // Assign the `str` to the end. + str := sub(m, 0x20) + // Zeroize the slot after the string. + mstore(str, 0) + + // Cache the end of the memory to calculate the length later. + let end := str + + // We write the string from rightmost digit to leftmost digit. + // The following is essentially a do-while loop that also handles the zero case. + // prettier-ignore + for { let temp := value } 1 {} { + str := sub(str, 1) + // Write the character to the pointer. + // The ASCII index of the '0' character is 48. + mstore8(str, add(48, mod(temp, 10))) + // Keep dividing `temp` until zero. + temp := div(temp, 10) + // prettier-ignore + if iszero(temp) { break } + } + + let length := sub(end, str) + // Move the pointer 32 bytes leftwards to make room for the length. + str := sub(str, 0x20) + // Store the length. + mstore(str, length) + } + } + + /** + * @dev For more efficient reverts. + */ + function _revert(bytes4 errorSelector) internal pure { + assembly { + mstore(0x00, errorSelector) + revert(0x00, 0x04) + } + } +} diff --git a/contracts/nft/erc721m/clones/ERC721AConduitPreapprovedCloneable.sol b/contracts/nft/erc721m/clones/ERC721AConduitPreapprovedCloneable.sol new file mode 100644 index 00000000..5fa7f9bd --- /dev/null +++ b/contracts/nft/erc721m/clones/ERC721AConduitPreapprovedCloneable.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {ERC721AQueryableCloneable} from "./ERC721AQueryableCloneable.sol"; +import {ERC721ACloneable} from "./ERC721ACloneable.sol"; +import {IERC721A} from "erc721a/contracts/IERC721A.sol"; + +/// @title ERC721AConduitPreapprovedCloneable +/// @notice ERC721A with the MagicEden conduit preapproved. +abstract contract ERC721AConduitPreapprovedCloneable is ERC721AQueryableCloneable { + /// @dev The canonical MagicEden conduit. + address internal constant _CONDUIT = 0x2052f8A2Ff46283B30084e5d84c89A2fdBE7f74b; + + /// @dev Returns if the `operator` is allowed to manage all of the + /// assets of `owner`. Always returns true for the conduit. + function isApprovedForAll(address owner, address operator) + public + view + virtual + override(ERC721ACloneable, IERC721A) + returns (bool) + { + if (operator == _CONDUIT) { + return true; + } + + return ERC721ACloneable.isApprovedForAll(owner, operator); + } +} diff --git a/contracts/nft/erc721m/clones/ERC721AQueryableCloneable.sol b/contracts/nft/erc721m/clones/ERC721AQueryableCloneable.sol new file mode 100644 index 00000000..194af043 --- /dev/null +++ b/contracts/nft/erc721m/clones/ERC721AQueryableCloneable.sol @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: MIT +// ERC721A Contracts v4.3.0 +// Creator: Chiru Labs + +pragma solidity ^0.8.4; + +import {IERC721AQueryable} from "erc721a/contracts/extensions/IERC721AQueryable.sol"; +import {ERC721ACloneable} from "./ERC721ACloneable.sol"; + +/** + * @title ERC721AQueryableCloneable. + * + * @dev ERC721A subclass with convenience query functions. + */ +abstract contract ERC721AQueryableCloneable is ERC721ACloneable, IERC721AQueryable { + /** + * @dev Returns the `TokenOwnership` struct at `tokenId` without reverting. + * + * If the `tokenId` is out of bounds: + * + * - `addr = address(0)` + * - `startTimestamp = 0` + * - `burned = false` + * - `extraData = 0` + * + * If the `tokenId` is burned: + * + * - `addr =
` + * - `startTimestamp = ` + * - `burned = true` + * - `extraData = ` + * + * Otherwise: + * + * - `addr =
` + * - `startTimestamp = ` + * - `burned = false` + * - `extraData = ` + */ + function explicitOwnershipOf(uint256 tokenId) + public + view + virtual + override + returns (TokenOwnership memory ownership) + { + unchecked { + if (tokenId >= _startTokenId()) { + if (tokenId > _sequentialUpTo()) return _ownershipAt(tokenId); + + if (tokenId < _nextTokenId()) { + // If the `tokenId` is within bounds, + // scan backwards for the initialized ownership slot. + while (!_ownershipIsInitialized(tokenId)) --tokenId; + return _ownershipAt(tokenId); + } + } + } + } + + /** + * @dev Returns an array of `TokenOwnership` structs at `tokenIds` in order. + * See {ERC721AQueryable-explicitOwnershipOf} + */ + function explicitOwnershipsOf(uint256[] calldata tokenIds) + external + view + virtual + override + returns (TokenOwnership[] memory) + { + TokenOwnership[] memory ownerships; + uint256 i = tokenIds.length; + assembly { + // Grab the free memory pointer. + ownerships := mload(0x40) + // Store the length. + mstore(ownerships, i) + // Allocate one word for the length, + // `tokenIds.length` words for the pointers. + i := shl(5, i) // Multiply `i` by 32. + mstore(0x40, add(add(ownerships, 0x20), i)) + } + while (i != 0) { + uint256 tokenId; + assembly { + i := sub(i, 0x20) + tokenId := calldataload(add(tokenIds.offset, i)) + } + TokenOwnership memory ownership = explicitOwnershipOf(tokenId); + assembly { + // Store the pointer of `ownership` in the `ownerships` array. + mstore(add(add(ownerships, 0x20), i), ownership) + } + } + return ownerships; + } + + /** + * @dev Returns an array of token IDs owned by `owner`, + * in the range [`start`, `stop`) + * (i.e. `start <= tokenId < stop`). + * + * This function allows for tokens to be queried if the collection + * grows too big for a single call of {ERC721AQueryable-tokensOfOwner}. + * + * Requirements: + * + * - `start < stop` + */ + function tokensOfOwnerIn(address owner, uint256 start, uint256 stop) + external + view + virtual + override + returns (uint256[] memory) + { + return _tokensOfOwnerIn(owner, start, stop); + } + + /** + * @dev Returns an array of token IDs owned by `owner`. + * + * This function scans the ownership mapping and is O(`totalSupply`) in complexity. + * It is meant to be called off-chain. + * + * See {ERC721AQueryable-tokensOfOwnerIn} for splitting the scan into + * multiple smaller scans if the collection is large enough to cause + * an out-of-gas error (10K collections should be fine). + */ + function tokensOfOwner(address owner) external view virtual override returns (uint256[] memory) { + // If spot mints are enabled, full-range scan is disabled. + if (_sequentialUpTo() != type(uint256).max) { + _revert(NotCompatibleWithSpotMints.selector); + } + uint256 start = _startTokenId(); + uint256 stop = _nextTokenId(); + uint256[] memory tokenIds; + if (start != stop) tokenIds = _tokensOfOwnerIn(owner, start, stop); + return tokenIds; + } + + /** + * @dev Helper function for returning an array of token IDs owned by `owner`. + * + * Note that this function is optimized for smaller bytecode size over runtime gas, + * since it is meant to be called off-chain. + */ + function _tokensOfOwnerIn(address owner, uint256 start, uint256 stop) + private + view + returns (uint256[] memory tokenIds) + { + unchecked { + if (start >= stop) _revert(InvalidQueryRange.selector); + // Set `start = max(start, _startTokenId())`. + if (start < _startTokenId()) start = _startTokenId(); + uint256 nextTokenId = _nextTokenId(); + // If spot mints are enabled, scan all the way until the specified `stop`. + uint256 stopLimit = _sequentialUpTo() != type(uint256).max ? stop : nextTokenId; + // Set `stop = min(stop, stopLimit)`. + if (stop >= stopLimit) stop = stopLimit; + // Number of tokens to scan. + uint256 tokenIdsMaxLength = balanceOf(owner); + // Set `tokenIdsMaxLength` to zero if the range contains no tokens. + if (start >= stop) tokenIdsMaxLength = 0; + // If there are one or more tokens to scan. + if (tokenIdsMaxLength != 0) { + // Set `tokenIdsMaxLength = min(balanceOf(owner), tokenIdsMaxLength)`. + if (stop - start <= tokenIdsMaxLength) { + tokenIdsMaxLength = stop - start; + } + uint256 m; // Start of available memory. + assembly { + // Grab the free memory pointer. + tokenIds := mload(0x40) + // Allocate one word for the length, and `tokenIdsMaxLength` words + // for the data. `shl(5, x)` is equivalent to `mul(32, x)`. + m := add(tokenIds, shl(5, add(tokenIdsMaxLength, 1))) + mstore(0x40, m) + } + // We need to call `explicitOwnershipOf(start)`, + // because the slot at `start` may not be initialized. + TokenOwnership memory ownership = explicitOwnershipOf(start); + address currOwnershipAddr; + // If the starting slot exists (i.e. not burned), + // initialize `currOwnershipAddr`. + // `ownership.address` will not be zero, + // as `start` is clamped to the valid token ID range. + if (!ownership.burned) currOwnershipAddr = ownership.addr; + uint256 tokenIdsIdx; + // Use a do-while, which is slightly more efficient for this case, + // as the array will at least contain one element. + do { + if (_sequentialUpTo() != type(uint256).max) { + // Skip the remaining unused sequential slots. + if (start == nextTokenId) start = _sequentialUpTo() + 1; + // Reset `currOwnershipAddr`, as each spot-minted token is a batch of one. + if (start > _sequentialUpTo()) { + currOwnershipAddr = address(0); + } + } + ownership = _ownershipAt(start); // This implicitly allocates memory. + assembly { + switch mload(add(ownership, 0x40)) + // if `ownership.burned == false`. + case 0 { + // if `ownership.addr != address(0)`. + // The `addr` already has it's upper 96 bits clearned, + // since it is written to memory with regular Solidity. + if mload(ownership) { currOwnershipAddr := mload(ownership) } + // if `currOwnershipAddr == owner`. + // The `shl(96, x)` is to make the comparison agnostic to any + // dirty upper 96 bits in `owner`. + if iszero(shl(96, xor(currOwnershipAddr, owner))) { + tokenIdsIdx := add(tokenIdsIdx, 1) + mstore(add(tokenIds, shl(5, tokenIdsIdx)), start) + } + } + // Otherwise, reset `currOwnershipAddr`. + // This handles the case of batch burned tokens + // (burned bit of first slot set, remaining slots left uninitialized). + default { currOwnershipAddr := 0 } + start := add(start, 1) + // Free temporary memory implicitly allocated for ownership + // to avoid quadratic memory expansion costs. + mstore(0x40, m) + } + } while (!(start == stop || tokenIdsIdx == tokenIdsMaxLength)); + // Store the length of the array. + assembly { + mstore(tokenIds, tokenIdsIdx) + } + } + } + } +} diff --git a/contracts/nft/erc721m/clones/ERC721MagicDropCloneable.sol b/contracts/nft/erc721m/clones/ERC721MagicDropCloneable.sol new file mode 100644 index 00000000..84156e83 --- /dev/null +++ b/contracts/nft/erc721m/clones/ERC721MagicDropCloneable.sol @@ -0,0 +1,465 @@ +// 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 {IERC721A} from "erc721a/contracts/IERC721A.sol"; + +import {ERC721MagicDropMetadataCloneable} from "./ERC721MagicDropMetadataCloneable.sol"; +import {ERC721ACloneable} from "./ERC721ACloneable.sol"; +import {IERC721MagicDropMetadata} from "../interfaces/IERC721MagicDropMetadata.sol"; +import {PublicStage, AllowlistStage, SetupConfig} from "./Types.sol"; + +/// ........ +/// ..... .. ... +/// .. ..... .. .. +/// .. ... ..... .. .. +/// .. ...... .. ...... .. +/// .. ......... ......... .... +/// .... .. .. ... +/// ........ ......... .. +/// .. ... ... .. ......... +/// .. .......... .... .... ....... ........ +/// ....... .. .. ... .... ..... .. +/// ........ . ... .. .. +/// . ..... ........ .... .. +/// .. .. ... ........... ... ... +/// ....... .. ...... ... .. +/// ............ ... ........ .. .. +/// ... ..... .. .. .. .. .. ...... +/// .. ........ ... .. .. .. .... .... +/// ....... .. .. ...... ....... .. +/// .. ..... .. .... .. +/// .. .... ......... . .. .. +/// ... .... .. ......... . .. .. +/// .... .... .. ..... ...... ... +/// ..... .. ........ ... ... +/// ... .. .. .. ...... ..... .. +/// .. .... ... ... .. .. +/// .. .... .. .. .. +/// . ...... .. .. .. +/// .. ...................... .............. +/// .. ................ .... ... +/// . ... ........ +/// .. ... ...... .. +/// .. .... ...EMMY.... +/// .. .. ... .... .... .. +/// .. .. ..... .......... +/// ... .. ... ...... +/// ... .... .. .. +/// .. ..... ... +/// ..... .... ........ ... +/// ........ .. ..... .......... +/// .. ........ .. ..MAGIC..... . +/// .... .... .... ..EDEN.... +/// ..... . ... ...... +/// .. ....... .. +/// ..... ..... +/// .... +/// @title ERC721MagicDropCloneable +/// @notice A cloneable ERC-721A 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 ERC721MagicDropCloneable is ERC721MagicDropMetadataCloneable { + /*============================================================== + = STORAGE = + ==============================================================*/ + + /// @dev Address that receives the primary sale proceeds of minted tokens. + /// Configurable by the owner. If unset, withdrawals may fail. + address private _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]. + PublicStage private _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. + AllowlistStage private _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 allowlist stage timing conflicts with the public stage timing. + error InvalidAllowlistStageTime(); + + /// @notice Thrown when the public stage timing conflicts with the allowlist stage timing. + error InvalidPublicStageTime(); + + /// @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-721 name of the collection. + /// @param _symbol The ERC-721 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 { + __ERC721ACloneable__init(_name, _symbol); + __ERC721MagicDropMetadataCloneable__init(_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 qty The number of tokens to mint. + function mintPublic(address to, uint256 qty) external payable { + PublicStage memory stage = _publicStage; + if (block.timestamp < stage.startTime || block.timestamp > stage.endTime) { + revert PublicStageNotActive(); + } + + uint256 requiredPayment = stage.price * qty; + if (msg.value != requiredPayment) { + revert RequiredValueNotMet(); + } + + if (_walletLimit > 0 && _numberMinted(to) + qty > _walletLimit) { + revert WalletLimitExceeded(); + } + + if (_totalMinted() + qty > _maxSupply) { + revert CannotExceedMaxSupply(); + } + + _safeMint(to, qty); + + if (stage.price != 0) { + _splitProceeds(); + } + + emit TokenMinted(to, _totalMinted(), 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 qty The number of tokens to mint. + /// @param proof The Merkle proof verifying `to` is eligible for the allowlist. + function mintAllowlist(address to, uint256 qty, bytes32[] calldata proof) external payable { + AllowlistStage memory stage = _allowlistStage; + 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 > 0 && _numberMinted(to) + qty > _walletLimit) { + revert WalletLimitExceeded(); + } + + if (_totalMinted() + qty > _maxSupply) { + revert CannotExceedMaxSupply(); + } + + _safeMint(to, qty); + + if (stage.price != 0) { + _splitProceeds(); + } + } + + /// @notice Burns a specific token. + /// @dev Only callable by the token owner or an approved operator. The token must exist. + /// @param tokenId The ID of the token to burn. + function burn(uint256 tokenId) external { + _burn(tokenId, true); + } + + /*============================================================== + = PUBLIC VIEW METHODS = + ==============================================================*/ + + /// @notice Returns the current configuration of the contract. + /// @return The current configuration of the contract. + function getConfig() external view returns (SetupConfig memory) { + SetupConfig memory newConfig = SetupConfig({ + maxSupply: _maxSupply, + walletLimit: _walletLimit, + baseURI: _baseURI(), + contractURI: _contractURI, + allowlistStage: _allowlistStage, + publicStage: _publicStage, + 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() external view returns (PublicStage memory) { + return _publicStage; + } + + /// @notice Returns the current allowlist stage configuration (startTime, endTime, price, merkleRoot). + /// @return The current allowlist stage settings. + function getAllowlistStage() external view returns (AllowlistStage memory) { + return _allowlistStage; + } + + /// @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(ERC721MagicDropMetadataCloneable) + returns (bool) + { + return interfaceId == type(IERC721MagicDropMetadata).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.maxSupply); + } + + if (config.walletLimit > 0) { + _setWalletLimit(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.allowlistStage); + } + + if (config.publicStage.startTime != 0 || config.publicStage.endTime != 0) { + _setPublicStage(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(PublicStage calldata stage) external onlyOwner { + _setPublicStage(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(AllowlistStage calldata stage) external onlyOwner { + _setAllowlistStage(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(PublicStage calldata stage) internal { + if (stage.startTime >= stage.endTime) { + revert InvalidStageTime(); + } + + // Ensure the public stage starts after the allowlist stage ends + if (_allowlistStage.startTime != 0 && _allowlistStage.endTime != 0) { + if (stage.startTime <= _allowlistStage.endTime) { + revert InvalidPublicStageTime(); + } + } + + _publicStage = 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 stage A struct defining allowlist stage timings, price, and merkle root. + function _setAllowlistStage(AllowlistStage calldata stage) internal { + if (stage.startTime >= stage.endTime) { + revert InvalidStageTime(); + } + + // Ensure the public stage starts after the allowlist stage ends + if (_publicStage.startTime != 0 && _publicStage.endTime != 0) { + if (stage.endTime >= _publicStage.startTime) { + revert InvalidAllowlistStageTime(); + } + } + + _allowlistStage = 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); + } + } + + /*============================================================== + = 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 ("ERC721MagicDropCloneable", "1.0.0"); + } + + /// @notice Retrieves the token metadata URI for a given token ID. + /// @dev If no base URI is set, returns an empty string. + /// If a trailing slash is present, tokenId is appended; otherwise returns just the base URI. + /// @param tokenId The ID of the token to retrieve the URI for. + /// @return The token's metadata URI as a string. + function tokenURI(uint256 tokenId) + public + view + virtual + override(ERC721ACloneable, IERC721A) + returns (string memory) + { + if (!_exists(tokenId)) revert URIQueryForNonexistentToken(); + + string memory baseURI = _baseURI(); + bool isBaseURIEmpty = bytes(baseURI).length == 0; + bool hasNoTrailingSlash = !isBaseURIEmpty && bytes(baseURI)[bytes(baseURI).length - 1] != bytes("/")[0]; + + if (isBaseURIEmpty) { + return ""; + } + if (hasNoTrailingSlash) { + return baseURI; + } + + return string(abi.encodePacked(baseURI, _toString(tokenId))); + } + + /*============================================================== + = 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/erc721m/clones/ERC721MagicDropMetadataCloneable.sol b/contracts/nft/erc721m/clones/ERC721MagicDropMetadataCloneable.sol new file mode 100644 index 00000000..ed94e3b9 --- /dev/null +++ b/contracts/nft/erc721m/clones/ERC721MagicDropMetadataCloneable.sol @@ -0,0 +1,231 @@ +// 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 {IERC721A} from "erc721a/contracts/IERC721A.sol"; + +import {ERC721AConduitPreapprovedCloneable} from "./ERC721AConduitPreapprovedCloneable.sol"; +import {ERC721ACloneable} from "./ERC721ACloneable.sol"; +import {IERC721MagicDropMetadata} from "../interfaces/IERC721MagicDropMetadata.sol"; + +/// @title ERC721MagicDropMetadataCloneable +/// @notice A cloneable ERC-721A implementation that supports adjustable metadata URIs, royalty configuration. +/// Inherits conduit-based preapprovals, making distribution more gas-efficient. +contract ERC721MagicDropMetadataCloneable is + ERC721AConduitPreapprovedCloneable, + IERC721MagicDropMetadata, + ERC2981, + Ownable +{ + /*============================================================== + = INITIALIZERS = + ==============================================================*/ + + /// @notice Initializes the contract. + /// @dev This function is called by the initializer of the parent contract. + /// @param owner The address of the contract owner. + function __ERC721MagicDropMetadataCloneable__init(address owner) internal onlyInitializing { + _initializeOwner(owner); + + emit MagicDropTokenDeployed(); + } + + /*============================================================== + = STORAGE = + ==============================================================*/ + + /// @notice The base URI used to construct `tokenURI` results. + /// @dev This value can be updated by the contract owner. Typically points to an off-chain IPFS/HTTPS endpoint. + string internal _tokenBaseURI; + + /// @notice A URI providing contract-level metadata (e.g., for marketplaces). + /// @dev Can be updated by the owner. Often returns metadata in a JSON format describing the project. + string internal _contractURI; + + /// @notice The maximum total number of tokens that can ever be minted. + /// @dev Acts as a cap on supply. Decreasing is allowed (if no tokens are over that limit), + /// but increasing supply is forbidden after initialization. + uint256 internal _maxSupply; + + /// @notice The per-wallet minting limit, restricting how many tokens a single address can mint. + uint256 internal _walletLimit; + + /// @notice The address receiving royalty payments. + address internal _royaltyReceiver; + + /// @notice The royalty amount (in basis points) for secondary sales (e.g., 100 = 1%). + uint96 internal _royaltyBps; + + /*============================================================== + = PUBLIC VIEW METHODS = + ==============================================================*/ + + /// @notice Returns the current base URI used to construct token URIs. + /// @return The base URI as a string. + function baseURI() public view override returns (string memory) { + return _tokenBaseURI; + } + + /// @notice Returns a URI representing contract-level metadata, often used by marketplaces. + /// @return The contract-level metadata URI. + function contractURI() public view override returns (string memory) { + return _contractURI; + } + + /// @notice The maximum number of tokens that can ever be minted by this contract. + /// @return The maximum supply of tokens. + function maxSupply() public view returns (uint256) { + return _maxSupply; + } + + /// @notice The maximum number of tokens any single wallet can mint. + /// @return The minting limit per wallet. + function walletLimit() public view returns (uint256) { + return _walletLimit; + } + + /// @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 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(ERC2981, ERC721ACloneable, IERC721A) + returns (bool) + { + return interfaceId == 0x2a55205a // ERC-2981 royalties + || interfaceId == 0x49064906 // ERC-4906 metadata updates + || ERC721ACloneable.supportsInterface(interfaceId); + } + + /*============================================================== + = 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); + + emit ContractURIUpdated(newContractURI); + } + + /// @notice Adjusts the maximum token supply. + /// @dev Cannot increase beyond the original max supply. Cannot set below the current minted amount. + /// @param newMaxSupply The new maximum supply. + function setMaxSupply(uint256 newMaxSupply) external onlyOwner { + _setMaxSupply(newMaxSupply); + } + + /// @notice Updates the per-wallet minting limit. + /// @dev This can be changed at any time to adjust distribution constraints. + /// @param newWalletLimit The new per-wallet limit on minted tokens. + function setWalletLimit(uint256 newWalletLimit) external onlyOwner { + _setWalletLimit(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 returning the current base URI for token metadata. + /// @return The current base URI string. + function _baseURI() internal view override returns (string memory) { + return _tokenBaseURI; + } + + /// @notice Internal function setting the base URI for token metadata. + /// @param newBaseURI The new base URI string. + function _setBaseURI(string calldata newBaseURI) internal { + _tokenBaseURI = newBaseURI; + + if (totalSupply() != 0) { + // Notify EIP-4906 compliant observers of a metadata update. + emit BatchMetadataUpdate(0, totalSupply() - 1); + } + } + + /// @notice Internal function setting the maximum token supply. + /// @dev Cannot increase beyond the original max supply. Cannot set below the current minted amount. + /// @param newMaxSupply The new maximum supply. + function _setMaxSupply(uint256 newMaxSupply) internal { + if (_maxSupply != 0 && newMaxSupply > _maxSupply) { + revert MaxSupplyCannotBeIncreased(); + } + + if (newMaxSupply < _totalMinted()) { + revert MaxSupplyCannotBeLessThanCurrentSupply(); + } + + if (newMaxSupply > 2 ** 64 - 1) { + revert MaxSupplyCannotBeGreaterThan2ToThe64thPower(); + } + + _maxSupply = newMaxSupply; + emit MaxSupplyUpdated(newMaxSupply); + } + + /// @notice Internal function setting the per-wallet minting limit. + /// @param newWalletLimit The new per-wallet limit on minted tokens. + function _setWalletLimit(uint256 newWalletLimit) internal { + _walletLimit = newWalletLimit; + emit WalletLimitUpdated(newWalletLimit); + } + + /// @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(_royaltyReceiver, _royaltyBps); + emit RoyaltyInfoUpdated(_royaltyReceiver, _royaltyBps); + } + + /// @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); + } +} diff --git a/contracts/nft/erc721m/clones/Types.sol b/contracts/nft/erc721m/clones/Types.sol new file mode 100644 index 00000000..3c849341 --- /dev/null +++ b/contracts/nft/erc721m/clones/Types.sol @@ -0,0 +1,47 @@ +// 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 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/erc721m/interfaces/IERC721MagicDropMetadata.sol b/contracts/nft/erc721m/interfaces/IERC721MagicDropMetadata.sol new file mode 100644 index 00000000..822c9058 --- /dev/null +++ b/contracts/nft/erc721m/interfaces/IERC721MagicDropMetadata.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {IMagicDropMetadata} from "contracts/common/interfaces/IMagicDropMetadata.sol"; + +interface IERC721MagicDropMetadata is IMagicDropMetadata { + /// @notice Emitted when the wallet limit is updated. + /// @param _walletLimit The new wallet limit. + event WalletLimitUpdated(uint256 _walletLimit); + + /// @notice Emitted when the max supply is updated. + /// @param newMaxSupply The new max supply. + event MaxSupplyUpdated(uint256 newMaxSupply); + + /// @notice Thrown when a mint would exceed the wallet-specific minting limit. + error WalletLimitExceeded(); + + /// @notice Returns the maximum number of tokens that can be minted per wallet + /// @dev Used to prevent excessive concentration of tokens in single wallets + /// @return The maximum number of tokens allowed per wallet address + function walletLimit() external view returns (uint256); + + /// @notice Returns the maximum number of tokens that can be minted + /// @dev This value cannot be increased once set, only decreased + /// @return The maximum supply cap for the collection + function maxSupply() external view returns (uint256); + + /// @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 walletLimit The new maximum number of tokens allowed per wallet + function setWalletLimit(uint256 walletLimit) external; + + /// @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 maxSupply The new maximum number of tokens that can be minted + function setMaxSupply(uint256 maxSupply) 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/erc721m/clones/ERC721MagicDropCloneable.t.sol b/test/erc721m/clones/ERC721MagicDropCloneable.t.sol new file mode 100644 index 00000000..4c74a922 --- /dev/null +++ b/test/erc721m/clones/ERC721MagicDropCloneable.t.sol @@ -0,0 +1,510 @@ +// 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 {ERC721MagicDropCloneable} from "contracts/nft/erc721m/clones/ERC721MagicDropCloneable.sol"; +import {IERC721MagicDropMetadata} from "contracts/nft/erc721m/interfaces/IERC721MagicDropMetadata.sol"; +import {PublicStage, AllowlistStage, SetupConfig} from "contracts/nft/erc721m/clones/Types.sol"; +import {IERC721MagicDropMetadata} from "contracts/nft/erc721m/interfaces/IERC721MagicDropMetadata.sol"; +import {IMagicDropMetadata} from "contracts/common/interfaces/IMagicDropMetadata.sol"; + +contract ERC721MagicDropCloneableTest is Test { + ERC721MagicDropCloneable 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; + + function setUp() public { + // Deploy a new token clone + token = ERC721MagicDropCloneable(LibClone.deployERC1967(address(new ERC721MagicDropCloneable()))); + + // 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); + + // 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; + + SetupConfig memory config = SetupConfig({ + 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 = + ==============================================================*/ + + function testInitialization() public { + assertEq(token.owner(), owner); + assertEq(token.name(), "TestToken"); + assertEq(token.symbol(), "TT"); + } + + function testMultipleInitializationReverts() 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, 1); + + assertEq(token.balanceOf(user), 1); + } + + function testMintPublicBeforeStartReverts() public { + // Before start + vm.warp(publicStart - 10); + vm.deal(user, 1 ether); + + vm.prank(user); + vm.expectRevert(ERC721MagicDropCloneable.PublicStageNotActive.selector); + token.mintPublic{value: 0.01 ether}(user, 1); + } + + function testMintPublicAfterEndReverts() public { + // After end + vm.warp(publicEnd + 10); + vm.deal(user, 1 ether); + + vm.prank(user); + vm.expectRevert(ERC721MagicDropCloneable.PublicStageNotActive.selector); + token.mintPublic{value: 0.01 ether}(user, 1); + } + + function testMintPublicNotEnoughValueReverts() public { + vm.warp(publicStart + 1); + vm.deal(user, 0.005 ether); + + vm.prank(user); + vm.expectRevert(ERC721MagicDropCloneable.RequiredValueNotMet.selector); + token.mintPublic{value: 0.005 ether}(user, 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, 5); + assertEq(token.balanceOf(user), 5); + + // Attempt to mint one more + vm.expectRevert(IERC721MagicDropMetadata.WalletLimitExceeded.selector); + token.mintPublic{value: 0.01 ether}(user, 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(0); + + vm.prank(user); + vm.expectRevert(IMagicDropMetadata.CannotExceedMaxSupply.selector); + token.mintPublic{value: 10.01 ether}(user, 1001); + } + + function testMintPublicOverpayReverts() public { + vm.warp(publicStart + 1); + + vm.deal(user, 1 ether); + + // Attempt to mint with excess Ether + vm.prank(user); + vm.expectRevert(ERC721MagicDropCloneable.RequiredValueNotMet.selector); + token.mintPublic{value: 0.02 ether}(user, 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, 1, proof); + assertEq(token.balanceOf(allowedAddr), 1); + } + + function testMintAllowlistInvalidProofReverts() public { + vm.warp(allowlistStart + 1); + + // Generate a proof for allowedAddr + bytes32[] memory proof = merkleHelper.getProofFor(allowedAddr); + + // We'll pass the minted tokens to a different address to invalidate the proof + vm.deal(allowedAddr, 1 ether); + vm.prank(allowedAddr); + + vm.expectRevert(ERC721MagicDropCloneable.InvalidProof.selector); + token.mintAllowlist{value: 0.005 ether}(user, 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(ERC721MagicDropCloneable.AllowlistStageNotActive.selector); + token.mintAllowlist{value: 0.005 ether}(allowedAddr, 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(ERC721MagicDropCloneable.RequiredValueNotMet.selector); + token.mintAllowlist{value: 0.001 ether}(allowedAddr, 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, 5, proof); + assertEq(token.balanceOf(allowedAddr), 5); + + vm.expectRevert(IERC721MagicDropMetadata.WalletLimitExceeded.selector); + token.mintAllowlist{value: 0.005 ether}(allowedAddr, 1, proof); + vm.stopPrank(); + } + + function testMintAllowlistMaxSupplyExceededReverts() public { + // Move time to allowlist + vm.warp(allowlistStart + 1); + + vm.prank(owner); + // unlimited wallet limit for the purpose of this test + token.setWalletLimit(0); + + bytes32[] memory proof = merkleHelper.getProofFor(allowedAddr); + vm.deal(allowedAddr, 5.005 ether); + + vm.prank(allowedAddr); + vm.expectRevert(IMagicDropMetadata.CannotExceedMaxSupply.selector); + token.mintAllowlist{value: 5.005 ether}(allowedAddr, 1001, 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, 1); + + uint256 tokenId = 0; + assertEq(token.ownerOf(tokenId), user); + + vm.prank(user); + token.burn(tokenId); + + vm.expectRevert(); + token.ownerOf(tokenId); + } + + function testBurnInvalidTokenReverts() public { + vm.prank(user); + vm.expectRevert(); + token.burn(9999); // non-existent token + } + + function testBurnNotOwnerReverts() public { + // mint to user + vm.warp(publicStart + 1); + vm.deal(user, 1 ether); + + vm.prank(user); + token.mintPublic{value: 0.01 ether}(user, 1); + uint256 tokenId = 1; + + vm.prank(user2); + vm.expectRevert(); + token.burn(tokenId); + } + + /*============================================================== + = GETTERS = + ==============================================================*/ + + function testGetPublicStage() public { + PublicStage memory ps = token.getPublicStage(); + assertEq(ps.startTime, publicStart); + assertEq(ps.endTime, publicEnd); + assertEq(ps.price, 0.01 ether); + } + + function testGetAllowlistStage() public view { + AllowlistStage memory als = token.getAllowlistStage(); + 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(IERC721MagicDropMetadata).interfaceId)); + } + + /*============================================================== + = ADMIN OPERATIONS = + ==============================================================*/ + + 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(ERC721MagicDropCloneable.InvalidStageTime.selector); + token.setPublicStage(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(ERC721MagicDropCloneable.InvalidStageTime.selector); + token.setAllowlistStage(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(ERC721MagicDropCloneable.InvalidPublicStageTime.selector); + token.setPublicStage(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(ERC721MagicDropCloneable.InvalidAllowlistStageTime.selector); + token.setAllowlistStage(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, 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(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, 1); + + // Check balances after minting + assertEq(token.PROTOCOL_FEE_RECIPIENT().balance, initialProtocolBalance); + assertEq(payoutRecipient.balance, initialPayoutBalance); + } + + 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(ERC721MagicDropCloneable.PayoutRecipientCannotBeZeroAddress.selector); + token.mintPublic{value: 0.01 ether}(user, 1); + } + + /*============================================================== + = METADATA = + ==============================================================*/ + + function testTokenURI() public { + vm.warp(publicStart + 1); + vm.deal(user, 1 ether); + vm.prank(user); + token.mintPublic{value: 0.01 ether}(user, 1); + string memory uri = token.tokenURI(0); + assertEq(uri, "https://example.com/metadata/0"); + } + + function testTokenURIWithEmptyBaseURI() public { + vm.warp(publicStart + 1); + vm.deal(user, 1 ether); + vm.prank(user); + token.mintPublic{value: 0.01 ether}(user, 1); + + vm.prank(owner); + token.setBaseURI(""); + assertEq(token.tokenURI(0), ""); + } + + function testTokenURIWithoutTrailingSlash() public { + vm.warp(publicStart + 1); + vm.deal(user, 1 ether); + vm.prank(user); + token.mintPublic{value: 0.01 ether}(user, 1); + + vm.prank(owner); + token.setBaseURI("https://example.com/metadata"); + assertEq(token.tokenURI(0), "https://example.com/metadata"); + } + + function testTokenURIForNonexistentTokenReverts() public { + vm.expectRevert(); + token.tokenURI(9999); + } + + 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/erc721m/clones/ERC721MagicDropMetadataCloneable.t.sol b/test/erc721m/clones/ERC721MagicDropMetadataCloneable.t.sol new file mode 100644 index 00000000..779f95e1 --- /dev/null +++ b/test/erc721m/clones/ERC721MagicDropMetadataCloneable.t.sol @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; + +import {Ownable} from "solady/src/auth/Ownable.sol"; +import {LibClone} from "solady/src/utils/LibClone.sol"; +import {IERC721A} from "erc721a/contracts/IERC721A.sol"; + +import {ERC721MagicDropMetadataCloneable} from "contracts/nft/erc721m/clones/ERC721MagicDropMetadataCloneable.sol"; +import {IERC721MagicDropMetadata} from "contracts/nft/erc721m/interfaces/IERC721MagicDropMetadata.sol"; +import {IMagicDropMetadata} from "contracts/common/interfaces/IMagicDropMetadata.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 TestableERC721MagicDropMetadataCloneable is ERC721MagicDropMetadataCloneable { + function initialize(address _owner) external initializer { + __ERC721MagicDropMetadataCloneable__init(_owner); + } + + function mintForTest(address to, uint256 quantity) external onlyOwner { + _mint(to, quantity); + } +} + +contract ERC721MagicDropMetadataCloneableTest is Test { + TestableERC721MagicDropMetadataCloneable token; + + address owner = address(0x1234); + address user = address(0xABCD); + address royaltyReceiver = address(0x9999); + + function setUp() public { + token = TestableERC721MagicDropMetadataCloneable( + LibClone.deployERC1967(address(new TestableERC721MagicDropMetadataCloneable())) + ); + token.initialize(owner); + } + + /*============================================================== + = INITIALIZATION = + ==============================================================*/ + + function testInitialization() public view { + assertEq(token.owner(), owner); + assertEq(token.maxSupply(), 0); + assertEq(token.walletLimit(), 0); + assertEq(token.baseURI(), ""); + assertEq(token.contractURI(), ""); + assertEq(token.royaltyAddress(), address(0)); + assertEq(token.royaltyBps(), 0); + } + + /*============================================================== + = 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"); + } + + /*============================================================== + = BASE URI = + ==============================================================*/ + + function testSetBaseURIWhenNoTokensMinted() public { + vm.prank(owner); + token.setBaseURI("https://example.com/metadata/"); + assertEq(token.baseURI(), "https://example.com/metadata/"); + // No tokens minted, so no BatchMetadataUpdate event expected + } + + function testSetBaseURIWithTokensMinted() public { + // Mint some tokens first + vm.startPrank(owner); + token.mintForTest(user, 5); // now totalSupply = 5 + vm.expectEmit(true, true, true, true); + emit IMagicDropMetadata.BatchMetadataUpdate(0, 4); + token.setBaseURI("https://example.com/metadata/"); + vm.stopPrank(); + + assertEq(token.baseURI(), "https://example.com/metadata/"); + } + + /*============================================================== + = CONTRACT URI = + ==============================================================*/ + + function testSetContractURI() public { + vm.prank(owner); + vm.expectEmit(false, false, false, true); + emit IMagicDropMetadata.ContractURIUpdated("https://new-contract-uri.json"); + token.setContractURI("https://new-contract-uri.json"); + assertEq(token.contractURI(), "https://new-contract-uri.json"); + } + + function testSetEmptyContractURI() public { + vm.prank(owner); + vm.expectEmit(false, false, false, true); + emit IMagicDropMetadata.ContractURIUpdated(""); + token.setContractURI(""); + assertEq(token.contractURI(), ""); + } + + /*============================================================== + = MAX SUPPLY = + ==============================================================*/ + + function testSetMaxSupplyBasic() public { + vm.prank(owner); + vm.expectEmit(false, false, false, true); + emit IERC721MagicDropMetadata.MaxSupplyUpdated(1000); + token.setMaxSupply(1000); + assertEq(token.maxSupply(), 1000); + } + + function testSetMaxSupplyDecreaseNotBelowMinted() public { + vm.startPrank(owner); + token.mintForTest(user, 10); + // Currently minted = 10 + vm.expectRevert(IMagicDropMetadata.MaxSupplyCannotBeLessThanCurrentSupply.selector); + token.setMaxSupply(5); + + // Setting exactly to 10 should pass + token.setMaxSupply(10); + assertEq(token.maxSupply(), 10); + } + + function testSetMaxSupplyCannotIncreaseBeyondOriginal() public { + vm.startPrank(owner); + token.setMaxSupply(1000); + vm.expectRevert(IMagicDropMetadata.MaxSupplyCannotBeIncreased.selector); + token.setMaxSupply(2000); + } + + /*============================================================== + = WALLET LIMIT = + ==============================================================*/ + + function testSetWalletLimit() public { + vm.prank(owner); + vm.expectEmit(false, false, false, true); + emit IERC721MagicDropMetadata.WalletLimitUpdated(20); + token.setWalletLimit(20); + assertEq(token.walletLimit(), 20); + } + + function testSetZeroWalletLimit() public { + vm.prank(owner); + vm.expectEmit(false, false, false, true); + emit IERC721MagicDropMetadata.WalletLimitUpdated(0); + token.setWalletLimit(0); + assertEq(token.walletLimit(), 0); + } + + /*============================================================== + = ROYALTY INFO = + ==============================================================*/ + + function testSetRoyaltyInfo() public { + vm.prank(owner); + vm.expectEmit(false, false, false, true); + emit IMagicDropMetadata.RoyaltyInfoUpdated(royaltyReceiver, 500); + token.setRoyaltyInfo(royaltyReceiver, 500); + + assertEq(token.royaltyAddress(), royaltyReceiver); + assertEq(token.royaltyBps(), 500); + + // Check ERC2981 royaltyInfo + (address receiver, uint256 amount) = IERC2981(address(token)).royaltyInfo(1, 10_000); + assertEq(receiver, royaltyReceiver); + assertEq(amount, 500); // 5% of 10000 = 500 + } + + function testSetRoyaltyInfoZeroAddress() public { + vm.prank(owner); + + vm.expectRevert(); + token.setRoyaltyInfo(address(0), 1000); + } + + /*============================================================== + = BATCH METADATA UPDATES = + ==============================================================*/ + + function testEmitBatchMetadataUpdate() public { + // Mint some tokens + vm.startPrank(owner); + token.mintForTest(user, 10); + + vm.expectEmit(true, true, true, true); + emit IMagicDropMetadata.BatchMetadataUpdate(2, 5); + token.emitBatchMetadataUpdate(2, 5); + vm.stopPrank(); + } + + /*============================================================== + = SUPPORTS INTERFACE = + ==============================================================*/ + + function testSupportsInterface() public view { + // ERC2981 interfaceId = 0x2a55205a + assertTrue(token.supportsInterface(0x2a55205a)); + // ERC4906 interfaceId = 0x49064906 + assertTrue(token.supportsInterface(0x49064906)); + // ERC721A interfaceId = 0x80ac58cd + assertTrue(token.supportsInterface(0x80ac58cd)); + // ERC721Metadata interfaceId = 0x5b5e139f + assertTrue(token.supportsInterface(0x5b5e139f)); + // Some random interface + assertFalse(token.supportsInterface(0x12345678)); + } + + /*============================================================== + = EDGE CASE TESTS = + ==============================================================*/ + + // If we never set maxSupply initially, setting it to something smaller than minted is invalid + function testCannotSetMaxSupplyLessThanMintedEvenIfNotSetBefore() public { + vm.startPrank(owner); + token.mintForTest(user, 5); + vm.expectRevert(IMagicDropMetadata.MaxSupplyCannotBeLessThanCurrentSupply.selector); + token.setMaxSupply(1); + } + + function testSetBaseURIEmptyString() public { + vm.prank(owner); + token.setBaseURI(""); + assertEq(token.baseURI(), ""); + } + + function testSetMaxSupplyToCurrentSupply() public { + vm.startPrank(owner); + token.mintForTest(user, 10); + token.setMaxSupply(10); + assertEq(token.maxSupply(), 10); + } + + function testMaxSupplyCannotBeGreaterThan2ToThe64thPower() public { + vm.startPrank(owner); + vm.expectRevert(IMagicDropMetadata.MaxSupplyCannotBeGreaterThan2ToThe64thPower.selector); + token.setMaxSupply(2 ** 64); + } +} 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; + } +}