// SPDX-License-Identifier: MIT pragma solidity ^0.8.30; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol"; interface ITransferValidator { function validateTransfer(address caller, address from, address to, uint256 tokenId) external view; } interface ICreatorToken { event TransferValidatorUpdated(address oldValidator, address newValidator); function getTransferValidator() external view returns (address); function getTransferValidationFunction() external view returns (bytes4, bool); function setTransferValidator(address validator) external; } /// @notice PoW mint with fixed ETH price steps, immediate artwork and 5% secondary-sale royalties. /// @dev Ownership configures a marketplace transfer validator only. No privileged mint, /// metadata change, upgrade, price change or difficulty change is available. contract RoninHash is ERC721, ERC2981, ReentrancyGuard, Ownable, ICreatorToken { using Strings for uint256; uint256 public immutable maxSupply; uint256 public immutable epochSize; uint256 public immutable startsAt; uint8 public immutable initialBits; uint256 public totalMinted; bytes32 public epochSeed; mapping(address => uint256) public workNonces; string public metadataBaseURI; uint256 public constant MAX_MINED_PER_WALLET = 2; address public immutable royaltyRecipient; uint256 public immutable mintStepWei; address private transferValidator; error InvalidConfiguration(); error MintNotOpen(); error SoldOut(); error StaleChallenge(); error InsufficientWork(); error WalletMiningLimitReached(); error InvalidValidator(); error IncorrectMintPayment(uint256 expected, uint256 received); error WithdrawalFailed(); event Mined(address indexed miner, uint256 indexed tokenId, bytes32 work, uint8 bits); event EpochChanged(uint256 indexed epoch, bytes32 seed, uint8 bits); event MintPayment(address indexed miner, uint256 indexed tokenId, uint256 amount); event ProceedsWithdrawn(address indexed recipient, uint256 amount); constructor( string memory collectionName, string memory collectionSymbol, uint256 supply, uint8 baseBits, uint256 stepSize, uint256 openAt, string memory metadataRoot, address recipient, uint256 stepWei ) ERC721(collectionName, collectionSymbol) Ownable(msg.sender) { if (supply == 0 || supply > 1111 || stepSize == 0 || baseBits < 8 || baseBits > 30 || uint256(baseBits) + _cappedEpoch((supply - 1) / stepSize) > 40 || bytes(collectionName).length == 0 || bytes(collectionName).length > 64 || bytes(collectionSymbol).length == 0 || bytes(collectionSymbol).length > 12) { revert InvalidConfiguration(); } bytes memory uri = bytes(metadataRoot); if (uri.length < 16 || uri.length > 160 || uri[0] != 'i' || uri[1] != 'p' || uri[2] != 'f' || uri[3] != 's' || uri[4] != ':' || uri[5] != '/' || uri[6] != '/' || uri[uri.length - 1] != '/') revert InvalidConfiguration(); metadataBaseURI = metadataRoot; if (recipient == address(0) || stepWei == 0 || stepWei > 0.01 ether) revert InvalidConfiguration(); royaltyRecipient = recipient; mintStepWei = stepWei; _setDefaultRoyalty(recipient, 500); maxSupply = supply; initialBits = baseBits; epochSize = stepSize; startsAt = openAt; epochSeed = keccak256(abi.encode(address(this), block.chainid, blockhash(block.number - 1))); } function currentEpoch() public view returns (uint256) { // Keep the last mint's chapter visible once the edition is complete. uint256 minted = totalMinted == maxSupply ? totalMinted - 1 : totalMinted; return _cappedEpoch(minted / epochSize); } function _cappedEpoch(uint256 epoch) private pure returns (uint256) { return epoch > 10 ? 10 : epoch; } function difficultyBits() public view returns (uint8) { return uint8(uint256(initialBits) + currentEpoch()); } function challenge(address miner) public view returns (bytes32) { return keccak256(abi.encode(address(this), block.chainid, epochSeed, miner, workNonces[miner], currentEpoch())); } function miningState(address miner) external view returns ( bytes32 workChallenge, uint8 bits, uint256 epoch, uint256 minted, uint256 supply, bool open, bool eligible ) { return (challenge(miner), difficultyBits(), currentEpoch(), totalMinted, maxSupply, block.timestamp >= startsAt && totalMinted < maxSupply, workNonces[miner] < MAX_MINED_PER_WALLET); } /// @param expectedChallenge The exact challenge searched by the miner. /// @param nonce A uint256 whose packed hash with the challenge clears the target. function mint(bytes32 expectedChallenge, uint256 nonce) external payable nonReentrant returns (uint256 tokenId) { if (block.timestamp < startsAt) revert MintNotOpen(); if (totalMinted >= maxSupply) revert SoldOut(); if (workNonces[msg.sender] >= MAX_MINED_PER_WALLET) revert WalletMiningLimitReached(); uint256 price = mintPrice(); if (msg.value != price) revert IncorrectMintPayment(price, msg.value); if (expectedChallenge != challenge(msg.sender)) revert StaleChallenge(); uint8 bits = difficultyBits(); bytes32 work = keccak256(abi.encodePacked(expectedChallenge, nonce)); if (uint256(work) > type(uint256).max >> bits) revert InsufficientWork(); uint256 previousEpoch = currentEpoch(); tokenId = ++totalMinted; ++workNonces[msg.sender]; if (currentEpoch() != previousEpoch && totalMinted < maxSupply) { epochSeed = keccak256(abi.encode(epochSeed, work, totalMinted)); emit EpochChanged(currentEpoch(), epochSeed, difficultyBits()); } _safeMint(msg.sender, tokenId); emit Mined(msg.sender, tokenId, work, bits); emit MintPayment(msg.sender, tokenId, price); } /// @notice #1-100 free; +1 fixed ETH step per 100 mints; capped at 10 steps. /// Public deployments use 100-token difficulty epochs, so both steps coincide. /// USD values are launch estimates; the ETH amounts never change. function mintPrice() public view returns (uint256) { return mintStepWei * _cappedEpoch(totalMinted / 100); } /// @notice Anyone can trigger a payout, always to the fixed creator recipient. /// Pull payments keep a reverting recipient from blocking other wallets' mints. function withdrawProceeds() external nonReentrant { uint256 amount = address(this).balance; (bool success,) = payable(royaltyRecipient).call{value: amount}(""); if (!success) revert WithdrawalFailed(); emit ProceedsWithdrawn(royaltyRecipient, amount); } function getTransferValidator() external view returns (address) { return transferValidator; } function getTransferValidationFunction() external pure returns (bytes4, bool) { return (ITransferValidator.validateTransfer.selector, true); } /// @notice Configure OpenSea's compatible registry after checking its deployment. /// A validator can restrict secondary transfers. Zero disables enforcement. function setTransferValidator(address validator) external onlyOwner { if (validator != address(0) && (validator == address(this) || validator.code.length == 0)) revert InvalidValidator(); emit TransferValidatorUpdated(transferValidator, validator); transferValidator = validator; } function _update(address to, uint256 tokenId, address auth) internal override returns (address) { address from = _ownerOf(tokenId); if (from != address(0) && to != address(0) && transferValidator != address(0)) { ITransferValidator(transferValidator).validateTransfer(msg.sender, from, to, tokenId); } return super._update(to, tokenId, auth); } function supportsInterface(bytes4 interfaceId) public view override(ERC721, ERC2981) returns (bool) { return interfaceId == type(ICreatorToken).interfaceId || super.supportsInterface(interfaceId); } /// @notice Final artwork is available as soon as a token exists. No reveal switch. function tokenURI(uint256 tokenId) public view override returns (string memory) { _requireOwned(tokenId); return string.concat(metadataBaseURI, tokenId.toString(), ".json"); } function contractURI() external view returns (string memory) { return string.concat(metadataBaseURI, "collection.json"); } }