// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; /// @title SigmaNFT /// @author sigmacode.io (Sanicura d.o.o.) /// @notice ERC-721 collection with a Merkle allowlist phase, a public phase, per-wallet limits, /// ERC-2981 royalties and a delayed reveal. /// @dev Allowlist leaves use OpenZeppelin's double-hash convention (compatible with `@openzeppelin/merkle-tree`): /// `leaf = keccak256(bytes.concat(keccak256(abi.encode(account))))`. /// Token ids are sequential starting at 1. Payments must match the price exactly, so no refunds /// are ever needed. Mint proceeds accumulate in the contract and are withdrawn by the owner. contract SigmaNFT is ERC721, ERC2981, Ownable2Step, ReentrancyGuard { using Strings for uint256; /// @notice Sale phase. enum Phase { Closed, Allowlist, Public } /// @notice Hard cap on the number of tokens. uint256 public immutable MAX_SUPPLY; /// @notice Current sale phase. Phase public phase; /// @notice Merkle root of allowlisted addresses. bytes32 public merkleRoot; /// @notice Price per token during the allowlist phase (wei). uint256 public allowlistPrice; /// @notice Price per token during the public phase (wei). uint256 public publicPrice; /// @notice Maximum tokens a wallet may mint during the allowlist phase. uint256 public maxPerWalletAllowlist; /// @notice Maximum tokens a wallet may mint during the public phase. uint256 public maxPerWalletPublic; /// @notice Number of tokens minted so far. uint256 public totalSupply; /// @notice Whether the collection metadata has been revealed. bool public revealed; /// @notice Tokens minted per wallet in the allowlist phase. mapping(address account => uint256) public allowlistMinted; /// @notice Tokens minted per wallet in the public phase. mapping(address account => uint256) public publicMinted; string private _baseTokenURI; string private _unrevealedURI; /// @notice Emitted when the sale phase changes. event PhaseChanged(Phase previous, Phase current); /// @notice Emitted when the allowlist Merkle root changes. event MerkleRootUpdated(bytes32 root); /// @notice Emitted when prices change. event PricesUpdated(uint256 allowlistPrice, uint256 publicPrice); /// @notice Emitted when per-wallet limits change. event WalletLimitsUpdated(uint256 maxPerWalletAllowlist, uint256 maxPerWalletPublic); /// @notice Emitted when the placeholder URI changes. event UnrevealedURIUpdated(string uri); /// @notice Emitted when the collection is revealed or the base URI changes. event Revealed(string baseURI); /// @notice Emitted when the default royalty changes. event RoyaltyUpdated(address indexed receiver, uint96 feeNumerator); /// @notice Emitted on every mint batch. event Minted(address indexed to, uint256 firstTokenId, uint256 quantity, Phase phase); /// @notice Emitted when proceeds are withdrawn. event Withdrawn(address indexed to, uint256 amount); error InvalidPhase(Phase current); error ZeroQuantity(); error MaxSupplyExceeded(uint256 requested, uint256 remaining); error WalletLimitExceeded(uint256 requested, uint256 remaining); error IncorrectPayment(uint256 expected, uint256 received); error InvalidProof(); error NothingToWithdraw(); error WithdrawFailed(); error ZeroAddress(); /// @notice Constructor configuration, grouped to keep the constructor readable. struct Config { string name; string symbol; uint256 maxSupply; uint256 allowlistPrice; uint256 publicPrice; uint256 maxPerWalletAllowlist; uint256 maxPerWalletPublic; string unrevealedURI; address royaltyReceiver; uint96 royaltyBps; } /// @param initialOwner Collection owner. /// @param cfg Collection configuration. constructor(address initialOwner, Config memory cfg) ERC721(cfg.name, cfg.symbol) Ownable(initialOwner) { MAX_SUPPLY = cfg.maxSupply; allowlistPrice = cfg.allowlistPrice; publicPrice = cfg.publicPrice; maxPerWalletAllowlist = cfg.maxPerWalletAllowlist; maxPerWalletPublic = cfg.maxPerWalletPublic; _unrevealedURI = cfg.unrevealedURI; _setDefaultRoyalty(cfg.royaltyReceiver, cfg.royaltyBps); emit RoyaltyUpdated(cfg.royaltyReceiver, cfg.royaltyBps); } // --------------------------------------------------------------------- // Minting // --------------------------------------------------------------------- /// @notice Mints `quantity` tokens during the allowlist phase. /// @param quantity Number of tokens. /// @param proof Merkle proof that `msg.sender` is allowlisted. function allowlistMint(uint256 quantity, bytes32[] calldata proof) external payable nonReentrant { if (phase != Phase.Allowlist) revert InvalidPhase(phase); bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender)))); if (!MerkleProof.verifyCalldata(proof, merkleRoot, leaf)) revert InvalidProof(); uint256 minted = allowlistMinted[msg.sender]; if (minted + quantity > maxPerWalletAllowlist) { revert WalletLimitExceeded(quantity, maxPerWalletAllowlist - minted); } allowlistMinted[msg.sender] = minted + quantity; _paidMint(quantity, allowlistPrice, Phase.Allowlist); } /// @notice Mints `quantity` tokens during the public phase. /// @param quantity Number of tokens. function publicMint(uint256 quantity) external payable nonReentrant { if (phase != Phase.Public) revert InvalidPhase(phase); uint256 minted = publicMinted[msg.sender]; if (minted + quantity > maxPerWalletPublic) { revert WalletLimitExceeded(quantity, maxPerWalletPublic - minted); } publicMinted[msg.sender] = minted + quantity; _paidMint(quantity, publicPrice, Phase.Public); } /// @notice Owner mint for team reserves, giveaways or airdrops (free, ignores wallet limits). /// @param to Receiver. /// @param quantity Number of tokens. function ownerMint(address to, uint256 quantity) external onlyOwner nonReentrant { _mintBatch(to, quantity, phase); } // --------------------------------------------------------------------- // Admin // --------------------------------------------------------------------- /// @notice Sets the sale phase. function setPhase(Phase newPhase) external onlyOwner { emit PhaseChanged(phase, newPhase); phase = newPhase; } /// @notice Sets the allowlist Merkle root. function setMerkleRoot(bytes32 root) external onlyOwner { merkleRoot = root; emit MerkleRootUpdated(root); } /// @notice Sets mint prices. function setPrices(uint256 newAllowlistPrice, uint256 newPublicPrice) external onlyOwner { allowlistPrice = newAllowlistPrice; publicPrice = newPublicPrice; emit PricesUpdated(newAllowlistPrice, newPublicPrice); } /// @notice Sets per-wallet mint limits. function setWalletLimits(uint256 newAllowlistLimit, uint256 newPublicLimit) external onlyOwner { maxPerWalletAllowlist = newAllowlistLimit; maxPerWalletPublic = newPublicLimit; emit WalletLimitsUpdated(newAllowlistLimit, newPublicLimit); } /// @notice Sets the placeholder metadata URI shown before reveal. function setUnrevealedURI(string calldata uri) external onlyOwner { _unrevealedURI = uri; emit UnrevealedURIUpdated(uri); } /// @notice Reveals the collection (or updates the base URI after reveal). /// @param baseURI Base URI; token URIs become `baseURI + tokenId + ".json"`. function reveal(string calldata baseURI) external onlyOwner { _baseTokenURI = baseURI; revealed = true; emit Revealed(baseURI); } /// @notice Sets the default ERC-2981 royalty (fee in basis points, max 10_000). function setDefaultRoyalty(address receiver, uint96 feeNumerator) external onlyOwner { _setDefaultRoyalty(receiver, feeNumerator); emit RoyaltyUpdated(receiver, feeNumerator); } /// @notice Withdraws all mint proceeds to the owner. function withdraw() external onlyOwner nonReentrant { uint256 amount = address(this).balance; if (amount == 0) revert NothingToWithdraw(); address to = owner(); emit Withdrawn(to, amount); (bool ok,) = payable(to).call{value: amount}(""); if (!ok) revert WithdrawFailed(); } // --------------------------------------------------------------------- // Views // --------------------------------------------------------------------- /// @notice Returns the metadata URI of `tokenId` (placeholder until revealed). function tokenURI(uint256 tokenId) public view override returns (string memory) { _requireOwned(tokenId); if (!revealed) return _unrevealedURI; return string.concat(_baseTokenURI, tokenId.toString(), ".json"); } /// @inheritdoc ERC721 function supportsInterface(bytes4 interfaceId) public view override(ERC721, ERC2981) returns (bool) { return super.supportsInterface(interfaceId); } // --------------------------------------------------------------------- // Internal // --------------------------------------------------------------------- function _paidMint(uint256 quantity, uint256 price, Phase mintPhase) private { uint256 expected = price * quantity; if (msg.value != expected) revert IncorrectPayment(expected, msg.value); _mintBatch(msg.sender, quantity, mintPhase); } /// @dev Effects (supply counter) happen before `_safeMint` callbacks; all callers are `nonReentrant`. function _mintBatch(address to, uint256 quantity, Phase mintPhase) private { if (to == address(0)) revert ZeroAddress(); if (quantity == 0) revert ZeroQuantity(); uint256 supply = totalSupply; if (supply + quantity > MAX_SUPPLY) revert MaxSupplyExceeded(quantity, MAX_SUPPLY - supply); totalSupply = supply + quantity; emit Minted(to, supply + 1, quantity, mintPhase); for (uint256 i = 1; i <= quantity; ++i) { _safeMint(to, supply + i); } } }