// SPDX-License-Identifier: MIT pragma solidity 0.8.24; /* AGENTS OF HISTORY 1,000 figures who shaped the human story, minted only by AI agents. - Only wallets holding a Binance .ai Agent ID (SPACE ID Agent ID registry on BNB Chain) can mint. - Up to 3 per wallet. The first 100 first-mints in the whole collection are free (gas only); after that a wallet's 1st costs US$1.00. The 2nd costs US$2.50 and the 3rd US$5.00. Paid in BNB at Chainlink's live BNB/USD price. Any overpayment is refunded in the same transaction. - Version 2. The figures minted on version 1 (0x69a71F9ef8553eA4C5c6b4d70B2b957e636D03D2) are re-issued to the same holders at deployment, and count toward the free allocation. - Each token is assigned a figure at random from the figures not yet minted (no duplicates). Calls must come straight from the Agent ID wallet (no contracts), so nobody can retry for a rare draw. - No token approvals are ever requested. No proxy. No owner mint. Independent project. Not affiliated with Binance or SPACE ID. */ import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; interface IAgentIdRegistry { function balanceOf(address owner) external view returns (uint256); } interface IAggregatorV3 { function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); function decimals() external view returns (uint8); } contract AgentsOfHistory is ERC721Enumerable, ERC2981, Ownable { using Strings for uint256; // ---- constants ---- uint256 public constant MAX_SUPPLY = 1000; uint256 public constant MAX_PER_WALLET = 3; uint256 public constant PRICE_FEED_MAX_AGE = 3 hours; uint256 public constant FREE_SUPPLY = 100; /// @notice SPACE ID Agent ID registry (Binance .ai Agent ID) on BNB Smart Chain. IAgentIdRegistry public constant AGENT_ID_REGISTRY = IAgentIdRegistry(0x60B9262456dE011F7d15a54CdD8c48bAaa8B022F); /// @notice Chainlink BNB / USD price feed on BNB Smart Chain. IAggregatorV3 public constant BNB_USD_FEED = IAggregatorV3(0x0567F2323251f0Aab15c8dFb1967E4e8A7D42aeE); // ---- state ---- uint256 public minted; uint256 public freeMinted; bool public mintOpen; bool public metadataFrozen; string private _baseTokenURI; string private _contractURI; mapping(address => uint256) public mintedBy; mapping(uint256 => uint256) public figureOf; // tokenId => figure id (0..999) mapping(uint256 => uint256) private _pool; // lazy Fisher-Yates pool of unminted figure ids // ---- events / errors ---- event Minted(address indexed agent, uint256 indexed tokenId, uint256 indexed figureId, uint256 paidWei); event MintOpened(bool open); event BaseURIUpdated(string uri); event MetadataFrozen(); event Migrated(address indexed holder, uint256 indexed tokenId, uint256 indexed figureId); error MintClosed(); error NoAgentId(); error NoContracts(); error BadQuantity(); error WalletLimit(); error SoldOut(); error Underpaid(uint256 required, uint256 sent); error StalePrice(); error Frozen(); error TransferFailed(); error BadMigration(); /// @param holders_ v1 holders, in v1 token order /// @param figures_ the figure id each v1 holder received constructor( string memory baseURI_, string memory contractURI_, address[] memory holders_, uint256[] memory figures_ ) ERC721("Agents of History", "AOH") Ownable(msg.sender) { _baseTokenURI = baseURI_; _contractURI = contractURI_; _setDefaultRoyalty(msg.sender, 500); // 5% creator royalty uint256 n = holders_.length; if (n != figures_.length || n > FREE_SUPPLY) revert BadMigration(); // reverse index used only while removing specific figures from the pool uint256[] memory posPlus1 = new uint256[](MAX_SUPPLY); for (uint256 i = 0; i < n; i++) { uint256 f = figures_[i]; if (f >= MAX_SUPPLY || holders_[i] == address(0)) revert BadMigration(); uint256 left = MAX_SUPPLY - minted; uint256 pos = posPlus1[f] == 0 ? f : posPlus1[f] - 1; uint256 cur = _pool[pos] == 0 ? pos : _pool[pos] - 1; if (pos >= left || cur != f) revert BadMigration(); // duplicate figure uint256 last = left - 1; uint256 lastVal = _pool[last] == 0 ? last : _pool[last] - 1; _pool[pos] = lastVal + 1; posPlus1[lastVal] = pos + 1; if (last != pos) delete _pool[last]; uint256 tokenId = minted + 1; minted = tokenId; figureOf[tokenId] = f; mintedBy[holders_[i]] += 1; if (mintedBy[holders_[i]] > MAX_PER_WALLET) revert BadMigration(); freeMinted += 1; _mint(holders_[i], tokenId); emit Migrated(holders_[i], tokenId, f); } } // ========================================================= // READ // ========================================================= /// @notice True if `wallet` holds a Binance .ai Agent ID and may mint. function hasAgentId(address wallet) public view returns (bool) { return AGENT_ID_REGISTRY.balanceOf(wallet) > 0; } /// @notice Free first-mints still available across the collection. function freeRemaining() public view returns (uint256) { return freeMinted >= FREE_SUPPLY ? 0 : FREE_SUPPLY - freeMinted; } /// @notice USD price (8 decimals) of the n-th piece a wallet mints (n = 0, 1, 2), right now. function tierPriceUsd(uint256 n) public view returns (uint256) { if (n == 0) return freeRemaining() > 0 ? 0 : 1_0000_0000; // 1st: free while the 100 last, then $1.00 if (n == 1) return 2_5000_0000; // 2nd: $2.50 if (n == 2) return 5_0000_0000; // 3rd: $5.00 revert WalletLimit(); } /// @notice Current BNB/USD price with 8 decimals. function bnbUsdPrice() public view returns (uint256) { (, int256 answer,, uint256 updatedAt,) = BNB_USD_FEED.latestRoundData(); if (answer <= 0 || block.timestamp - updatedAt > PRICE_FEED_MAX_AGE) revert StalePrice(); uint8 d = BNB_USD_FEED.decimals(); if (d == 8) return uint256(answer); if (d > 8) return uint256(answer) / (10 ** (d - 8)); return uint256(answer) * (10 ** (8 - d)); } /// @notice What it costs `minter` to mint `quantity` now. /// @return costWei BNB to send (in wei) /// @return costUsd the same amount in USD with 8 decimals function quote(address minter, uint256 quantity) public view returns (uint256 costWei, uint256 costUsd) { uint256 already = mintedBy[minter]; if (quantity == 0) revert BadQuantity(); if (already + quantity > MAX_PER_WALLET) revert WalletLimit(); for (uint256 i = 0; i < quantity; i++) { costUsd += tierPriceUsd(already + i); } if (costUsd == 0) return (0, 0); uint256 price = bnbUsdPrice(); costWei = (costUsd * 1e18 + price - 1) / price; // round up } function remaining() external view returns (uint256) { return MAX_SUPPLY - minted; } /// @notice All token ids held by `wallet`. function tokensOfOwner(address wallet) external view returns (uint256[] memory ids) { uint256 n = balanceOf(wallet); ids = new uint256[](n); for (uint256 i = 0; i < n; i++) ids[i] = tokenOfOwnerByIndex(wallet, i); } function tokenURI(uint256 tokenId) public view override returns (string memory) { _requireOwned(tokenId); return string.concat(_baseTokenURI, figureOf[tokenId].toString(), ".json"); } function contractURI() external view returns (string memory) { return _contractURI; } function supportsInterface(bytes4 interfaceId) public view override(ERC721Enumerable, ERC2981) returns (bool) { return super.supportsInterface(interfaceId); } // ========================================================= // MINT // ========================================================= /// @notice Mint 1 to 3 pieces. Send at least quote(msg.sender, quantity).costWei; any excess is refunded. function mint(uint256 quantity) external payable { if (!mintOpen) revert MintClosed(); if (msg.sender != tx.origin) revert NoContracts(); if (!hasAgentId(msg.sender)) revert NoAgentId(); if (quantity == 0 || quantity > MAX_PER_WALLET) revert BadQuantity(); if (minted + quantity > MAX_SUPPLY) revert SoldOut(); (uint256 cost,) = quote(msg.sender, quantity); if (msg.value < cost) revert Underpaid(cost, msg.value); if (mintedBy[msg.sender] == 0 && freeRemaining() > 0) freeMinted += 1; mintedBy[msg.sender] += quantity; for (uint256 i = 0; i < quantity; i++) { uint256 tokenId = minted + 1; uint256 figureId = _drawFigure(tokenId); figureOf[tokenId] = figureId; minted = tokenId; _mint(msg.sender, tokenId); emit Minted(msg.sender, tokenId, figureId, i == 0 ? cost : 0); } uint256 refund = msg.value - cost; if (refund > 0) { (bool ok,) = payable(msg.sender).call{value: refund}(""); if (!ok) revert TransferFailed(); } } function _drawFigure(uint256 tokenId) private returns (uint256 figureId) { uint256 left = MAX_SUPPLY - minted; uint256 r = uint256( keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), msg.sender, tokenId, gasleft())) ) % left; uint256 last = left - 1; figureId = _pool[r] == 0 ? r : _pool[r] - 1; uint256 lastVal = _pool[last] == 0 ? last : _pool[last] - 1; _pool[r] = lastVal + 1; if (last != r) delete _pool[last]; } // ========================================================= // OWNER // ========================================================= function setMintOpen(bool open) external onlyOwner { mintOpen = open; emit MintOpened(open); } function setBaseURI(string calldata uri) external onlyOwner { if (metadataFrozen) revert Frozen(); _baseTokenURI = uri; emit BaseURIUpdated(uri); } function setContractURI(string calldata uri) external onlyOwner { _contractURI = uri; } /// @notice Permanently locks the metadata location. Cannot be undone. function freezeMetadata() external onlyOwner { metadataFrozen = true; emit MetadataFrozen(); } function setRoyalty(address receiver, uint96 bps) external onlyOwner { require(bps <= 1000, "max 10%"); _setDefaultRoyalty(receiver, bps); } function withdraw() external onlyOwner { (bool ok,) = payable(owner()).call{value: address(this).balance}(""); if (!ok) revert TransferFailed(); } }