// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
/// @notice Fixed-price, fixed-supply artwork. No proxy, royalties or owner mints.
/// @dev Payment token must be native six-decimal USDC; deploy script enforces it.
contract Aftercurrent is ERC721, Ownable2Step, ReentrancyGuard {
using SafeERC20 for IERC20;
using Strings for uint256;
uint256 public constant MAX_SUPPLY = 1000;
uint256 public constant PRICE = 50_000_000;
uint256 public constant MAX_PER_TX = 5;
uint256 public constant MAX_PER_WALLET = 10;
IERC20 public immutable paymentToken;
address public immutable treasury;
uint256 public totalMinted;
bool public saleOpen;
mapping(address => uint256) public purchased;
event SaleStatusChanged(bool open);
event Collected(address indexed collector, uint256 firstTokenId, uint256 quantity, uint256 paid);
error InvalidAddress();
error SaleClosed();
error InvalidQuantity();
error SupplyExceeded();
error WalletLimit();
constructor(address initialOwner, address recipient, address usdc)
ERC721("Aftercurrent 001", "AFT001") Ownable(initialOwner) {
if (recipient == address(0) || usdc.code.length == 0) revert InvalidAddress();
treasury = recipient;
paymentToken = IERC20(usdc);
}
function setSaleOpen(bool open) external onlyOwner {
saleOpen = open;
emit SaleStatusChanged(open);
}
function mint(uint256 quantity) external nonReentrant {
if (!saleOpen) revert SaleClosed();
if (quantity == 0 || quantity > MAX_PER_TX) revert InvalidQuantity();
if (totalMinted + quantity > MAX_SUPPLY) revert SupplyExceeded();
if (purchased[msg.sender] + quantity > MAX_PER_WALLET) revert WalletLimit();
uint256 first = totalMinted + 1;
totalMinted += quantity;
purchased[msg.sender] += quantity;
paymentToken.safeTransferFrom(msg.sender, treasury, PRICE * quantity);
for (uint256 i; i < quantity; ++i) _safeMint(msg.sender, first + i);
emit Collected(msg.sender, first, quantity, PRICE * quantity);
}
/// @notice Preview is deterministic and public, even for unminted IDs.
function artwork(uint256 id) public pure returns (string memory) {
require(id > 0 && id <= MAX_SUPPLY, "Invalid token ID");
string[5] memory colors = [string("#b8f57a"), "#fe986b", "#bba6ff", "#7bdae6", "#f3d580"];
string memory color = colors[(id - 1) % 5];
string memory shapes;
for (uint256 i; i < 24; ++i) {
uint256 seed = uint256(keccak256(abi.encode(id, i)));
uint256 y = 120 + i * 22;
uint256 bend = 120 + seed % 480;
uint256 end = 80 + (seed >> 16) % 560;
shapes = string.concat(shapes, '');
}
return string.concat('');
}
function tokenURI(uint256 id) public view override returns (string memory) {
_requireOwned(id);
string memory body = string.concat('{"name":"Aftercurrent 001 #', id.toString(),
'","description":"An AI-assisted generative drawing. 24 currents meet in one field. Artwork and metadata are generated by the NFT contract. Art offered under CC0 1.0 to the extent rights exist. No financial rights or promised utility.","image":"data:image/svg+xml;base64,',
Base64.encode(bytes(artwork(id))), '","attributes":[{"trait_type":"Palette","value":',
(((id - 1) % 5) + 1).toString(), '}]}');
return string.concat("data:application/json;base64,", Base64.encode(bytes(body)));
}
}