// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; 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 {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; interface IAgentIdentity { function balanceOf(address account) external view returns (uint256); } /// @notice Elemental art collectibles. No fungible token, redemption or yield entitlement. /// @dev Fixed rules, immutable beneficiary and image root. No owner/admin/upgrades. /// Referral parents must have previously minted, making the immutable graph acyclic. contract Aetherkin is ERC721, ERC2981, ReentrancyGuard { using Strings for uint256; uint256 public constant MAX_SUPPLY = 3000; uint256 public constant MAX_PER_WALLET = 20; uint256 public constant FIRST_PRICE = 0.001 ether; uint256 public constant PRICE = 0.002 ether; uint256 public constant MINT_DURATION = 5 days; uint256 public constant REFERRAL_DEPTH = 3; uint96 public constant ROYALTY_BPS = 100; address public immutable creator; IAgentIdentity public immutable AGENT_ID; string public artBaseURI; uint256 public immutable mintStart; uint256 public immutable mintEnd; uint256 public totalSupply; uint256 public bonusMinted; uint256 public totalMintBNB; uint256 public totalReferralBNB; uint256 public pendingTotal; mapping(address => uint256) public mintedBy; mapping(address => address) public referrerOf; mapping(address => uint256) public referralEarned; mapping(address => uint256) public pendingBNB; mapping(uint256 => uint8) public cardType; event ReferralBound(address indexed collector, address indexed referrer); event ReferralReward(address indexed collector, address indexed beneficiary, uint8 level, uint256 amount, bool delivered); event PaymentDeferred(address indexed beneficiary, uint256 amount); event BNBClaimed(address indexed beneficiary, address indexed recipient, uint256 amount); event Milestone(address indexed collector, uint256 reached, uint256 indexed tokenId); event Minted(address indexed collector, uint256 quantity, uint256 bonus, uint256 paid); error InvalidConfiguration(); error NotAgent(); error MintClosed(); error SoldOut(); error WalletLimit(); error BadQuantity(); error WrongPayment(); error SelfReferral(); error ReferrerInactive(); error ReferralLocked(); error NoPendingBNB(); error TransferFailed(); constructor(address creator_, address registry_, string memory artBaseURI_) ERC721("AETHERKIN", "AKIN") { bytes memory uri = bytes(artBaseURI_); bool https = uri.length >= 9 && bytes8(uri) == bytes8("https://"); bool ipfs = uri.length >= 8 && bytes7(uri) == bytes7("ipfs://"); if (creator_ == address(0) || registry_.code.length == 0 || uri.length > 256 || (!https && !ipfs)) revert InvalidConfiguration(); // Reject quote/backslash/control injection into on-chain JSON metadata. for (uint256 i; i < uri.length; ++i) if (uint8(uri[i]) < 33 || uri[i] == '"' || uri[i] == '\\') revert InvalidConfiguration(); if (uri[uri.length - 1] == '/') revert InvalidConfiguration(); creator = creator_; AGENT_ID = IAgentIdentity(registry_); artBaseURI = artBaseURI_; mintStart = block.timestamp; mintEnd = block.timestamp + MINT_DURATION; _setDefaultRoyalty(creator_, ROYALTY_BPS); } function priceFor(address collector, uint256 quantity) public view returns (uint256) { if (quantity == 0) return 0; return mintedBy[collector] == 0 ? FIRST_PRICE + (quantity - 1) * PRICE : quantity * PRICE; } function bonusFor(address collector, uint256 quantity) public view returns (uint256 count) { uint256 before_ = mintedBy[collector]; uint256 after_ = before_ + quantity; if (before_ < 10 && after_ >= 10) ++count; if (before_ < 15 && after_ >= 15) ++count; if (before_ < 20 && after_ >= 20) ++count; } function referralBps(uint256 level) public pure returns (uint256) { return level == 0 ? 2500 : level == 1 ? 1500 : level == 2 ? 1000 : 0; } function remaining() external view returns (uint256) { return MAX_SUPPLY - totalSupply; } function mintOpen() external view returns (bool) { return block.timestamp < mintEnd && totalSupply < MAX_SUPPLY; } function hasAgentId(address account) external view returns (bool) { return AGENT_ID.balanceOf(account) > 0; } function mint(uint256 quantity, address proposedReferrer) external payable nonReentrant { if (quantity == 0 || quantity > MAX_PER_WALLET) revert BadQuantity(); if (block.timestamp >= mintEnd) revert MintClosed(); if (AGENT_ID.balanceOf(msg.sender) == 0) revert NotAgent(); uint256 before_ = mintedBy[msg.sender]; if (before_ + quantity > MAX_PER_WALLET) revert WalletLimit(); if (msg.value != priceFor(msg.sender, quantity)) revert WrongPayment(); uint256 bonuses = bonusFor(msg.sender, quantity); if (totalSupply + quantity + bonuses > MAX_SUPPLY) revert SoldOut(); if (before_ == 0) { if (proposedReferrer == msg.sender) revert SelfReferral(); if (proposedReferrer != address(0) && mintedBy[proposedReferrer] == 0) revert ReferrerInactive(); referrerOf[msg.sender] = proposedReferrer; emit ReferralBound(msg.sender, proposedReferrer); } else if (proposedReferrer != address(0) && proposedReferrer != referrerOf[msg.sender]) revert ReferralLocked(); uint256 id = totalSupply; totalSupply += quantity + bonuses; bonusMinted += bonuses; mintedBy[msg.sender] = before_ + quantity; totalMintBNB += msg.value; for (uint256 i; i < quantity; ++i) { uint256 nth = before_ + i + 1; cardType[++id] = uint8(nth - 1); _safeMint(msg.sender, id); if (nth == 10 || nth == 15 || nth == 20) { cardType[++id] = uint8(nth == 10 ? 20 : nth == 15 ? 21 : 22); _safeMint(msg.sender, id); emit Milestone(msg.sender, nth, id); } } uint256 distributed; address parent = referrerOf[msg.sender]; for (uint8 level; level < REFERRAL_DEPTH && parent != address(0); ++level) { uint256 amount = msg.value * referralBps(level) / 10000; distributed += amount; totalReferralBNB += amount; referralEarned[parent] += amount; bool delivered = _payOrCredit(parent, amount); emit ReferralReward(msg.sender, parent, level + 1, amount, delivered); parent = referrerOf[parent]; } // All unallocated levels and integer rounding belong to the immutable creator. _payOrCredit(creator, msg.value - distributed); emit Minted(msg.sender, quantity, bonuses, msg.value); } function _payOrCredit(address recipient, uint256 amount) private returns (bool delivered) { if (amount == 0) return true; (delivered,) = payable(recipient).call{value: amount, gas: 30000}(""); if (!delivered) { pendingBNB[recipient] += amount; pendingTotal += amount; emit PaymentDeferred(recipient, amount); } } /// @notice Only the beneficiary can claim its own deferred payout, to a chosen payable address. function claimBNB(address payable recipient) external nonReentrant { if (recipient == address(0)) revert InvalidConfiguration(); uint256 amount = pendingBNB[msg.sender]; if (amount == 0) revert NoPendingBNB(); pendingBNB[msg.sender] = 0; pendingTotal -= amount; (bool ok,) = recipient.call{value: amount}(""); if (!ok) revert TransferFailed(); emit BNBClaimed(msg.sender, recipient, amount); } function supportsInterface(bytes4 interfaceId) public view override(ERC721, ERC2981) returns (bool) { return super.supportsInterface(interfaceId); } function character(uint8 t) public pure returns (string memory label, string memory element, string memory file, string memory edition) { require(t < 23, "Unknown card"); if (t == 20) return ("CONVERGENCE", "All Elements", "convergence.webp", "Milestone 10"); if (t == 21) return ("NEURAL NEXUS", "All Elements", "nexus.webp", "Milestone 15"); if (t == 22) return ("SINGULARITY", "All Elements", "singularity.webp", "Milestone 20"); string[10] memory names = ["EMBER", "TIDE", "GALE", "TERRA", "VOLT", "FROST", "VERDANT", "ALLOY", "LUMEN", "VOID"]; string[10] memory elements = ["Fire", "Water", "Wind", "Earth", "Lightning", "Ice", "Nature", "Metal", "Light", "Void"]; string[10] memory files = ["ember.webp", "tide.webp", "gale.webp", "terra.webp", "volt.webp", "frost.webp", "verdant.webp", "alloy.webp", "lumen.webp", "void.webp"]; uint256 index = t % 10; return (names[index], elements[index], files[index], t < 10 ? "Genesis" : "Ascended"); } /// @notice Artwork-specific story shared by the matching Genesis/Ascended editions. function characterDescription(uint8 t) public pure returns (string memory) { require(t < 23, "Unknown card"); string[13] memory descriptions = [ "A molten-hearted fennec fox. Its radiant ears catch the first sparks of a newborn star.", "A pearl-blue axolotl carrying an ocean in its core. Every floating droplet holds a memory of the cosmos.", "A long-eared pathfinder riding mint-green currents. Wherever the orbits lead, Gale arrives one breeze ahead.", "A gentle stone-bear guardian with a golden crystal heart. Ancient worlds endure in its moss-covered armor.", "A mischievous tiger cub tuned to the storm. Twin orbiting conductors turn every step into a pulse of light.", "A little penguin crowned in star-born ice. Beneath the frozen silence, its blue core shines with wonder.", "A forest-hearted red panda wrapped in living circuitry. Emerald vines trace new life through forgotten machines.", "A compact rhino sentinel forged in silver. A magnetic orbit follows its unwavering, quietly curious heart.", "A golden-winged owl with a sunlit core. Its halo becomes a beacon when the farthest stars fall silent.", "A midnight feline listening to the space between stars. Violet rings reveal the gravity of its hidden core.", "A small dragon carrying ten elemental lights in one orbit. The first moment the frequencies become a constellation.", "A nine-tailed cosmic fox woven from neural light. Each luminous tail remembers a different path through the universe.", "An antler-crowned qilin at the heart of the spectrum. Every element meets in its iridescent singularity core." ]; return descriptions[t < 20 ? t % 10 : t - 10]; } function tokenURI(uint256 tokenId) public view override returns (string memory) { _requireOwned(tokenId); uint8 t = cardType[tokenId]; (string memory label, string memory element, string memory file, string memory edition) = character(t); return string.concat("data:application/json;base64,", Base64.encode(abi.encodePacked( '{"name":"AETHERKIN #', tokenId.toString(), ' - ', label, '","description":"', characterDescription(t), ' AI-inspired elemental art collectible. Ten characters in Genesis and Ascended editions share character artwork; three separate milestone artworks. Fixed mint order. No fungible token, redemption or yield entitlement.",', '"image":"', artBaseURI, '/', file, '","attributes":[{"trait_type":"Character","value":"', label, '"},{"trait_type":"Element","value":"', element, '"},{"trait_type":"Edition","value":"', edition, '"}]}' ))); } }