// SPDX-License-Identifier: MIT pragma solidity ^0.8.30; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; interface IWETH { function deposit() external payable; function transfer(address to, uint256 amount) external returns (bool); } interface IV2Factory { function getPair(address, address) external view returns (address); function createPair(address, address) external returns (address); } interface IV2Pair { function mint(address to) external returns (uint256); function totalSupply() external view returns (uint256); } /// @notice No owner, functions, upgrade mechanism or withdrawal path. contract PermanentLiquidityLock {} /// @notice Plain fixed-supply ERC20. No tax, privileged mint or blacklist. contract MOBToken is ERC20 { constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) { _mint(msg.sender, 1_000_000_000 ether); } } /// @notice Immutable equal-seat launchpad. One wallet does not mean one person. contract MOBLaunchpad is ReentrancyGuard { uint256 public constant SEAT_PRICE = 0.01 ether; uint256 public constant SEATS = 100; uint256 public constant TOKENS_PER_SEAT = 5_000_000 ether; uint256 public constant LIQUIDITY_TOKENS = 500_000_000 ether; uint256 public constant WINDOW = 24 hours; IV2Factory public immutable factory; IWETH public immutable weth; address public immutable liquidityLock; uint256 public mobCount; uint256 public totalEscrow; struct Mob { string name; string ticker; string description; address creator; uint64 deadline; uint16 seats; uint8 avatar; address token; address pair; } mapping(uint256 => Mob) private mobs; // 0 none; 1 reserved; 2 claimed; 3 refunded (may rejoin before expiry). mapping(uint256 => mapping(address => uint8)) public seatState; mapping(bytes32 => bool) public tickerUsed; mapping(address => uint256[]) private accountMobs; mapping(uint256 => mapping(address => bool)) private accountListed; error InvalidConfiguration(); error InvalidMetadata(); error TickerTaken(); error UnknownMob(); error Closed(); error WrongValue(); error AlreadyReserved(); error NoReservation(); error NotLaunched(); error TransferFailed(); error InvalidRecipient(); error InvalidPool(); event MobCreated(uint256 indexed id, address indexed creator, string ticker, uint256 deadline); event SeatReserved(uint256 indexed id, address indexed account, uint256 seats); event SeatRefunded(uint256 indexed id, address indexed account, address recipient); event MobLaunched(uint256 indexed id, address indexed token, address pair); event TokensClaimed(uint256 indexed id, address indexed account, address recipient); constructor(address factory_, address weth_) { if (factory_.code.length == 0 || weth_.code.length == 0 || factory_ == weth_) revert InvalidConfiguration(); factory = IV2Factory(factory_); weth = IWETH(weth_); liquidityLock = address(new PermanentLiquidityLock()); } function createMob(string calldata name, string calldata ticker, string calldata description, uint8 avatar) external nonReentrant returns (uint256 id) { bytes memory symbol = bytes(ticker); if (bytes(name).length < 2 || bytes(name).length > 28 || symbol.length < 2 || symbol.length > 8 || bytes(description).length > 160 || avatar > 3) revert InvalidMetadata(); for (uint256 i; i < symbol.length; ++i) { bytes1 c = symbol[i]; if (!((c >= 0x41 && c <= 0x5a) || (c >= 0x30 && c <= 0x39))) revert InvalidMetadata(); } bytes32 key = keccak256(symbol); if (tickerUsed[key]) revert TickerTaken(); tickerUsed[key] = true; id = ++mobCount; mobs[id] = Mob(name, ticker, description, msg.sender, uint64(block.timestamp + WINDOW), 0, avatar, address(0), address(0)); _list(id, msg.sender); emit MobCreated(id, msg.sender, ticker, block.timestamp + WINDOW); } function reserve(uint256 id) external payable nonReentrant { Mob storage mob = _mob(id); if (mob.token != address(0) || block.timestamp >= mob.deadline || mob.seats >= SEATS) revert Closed(); if (msg.value != SEAT_PRICE) revert WrongValue(); if (seatState[id][msg.sender] == 1 || seatState[id][msg.sender] == 2) revert AlreadyReserved(); seatState[id][msg.sender] = 1; _list(id, msg.sender); ++mob.seats; totalEscrow += msg.value; emit SeatReserved(id, msg.sender, mob.seats); if (mob.seats == SEATS) _launch(id, mob); } /// @notice Withdraw any time before launch, including after expiry. /// @param recipient Alternative address for wallets that reject ETH. function refund(uint256 id, address payable recipient) external nonReentrant { Mob storage mob = _mob(id); if (mob.token != address(0)) revert Closed(); if (seatState[id][msg.sender] != 1) revert NoReservation(); if (recipient == address(0) || recipient == address(this)) revert InvalidRecipient(); seatState[id][msg.sender] = 3; --mob.seats; totalEscrow -= SEAT_PRICE; (bool ok,) = recipient.call{value: SEAT_PRICE}(""); if (!ok) revert TransferFailed(); emit SeatRefunded(id, msg.sender, recipient); } function claim(uint256 id, address recipient) external nonReentrant { Mob storage mob = _mob(id); if (mob.token == address(0)) revert NotLaunched(); if (seatState[id][msg.sender] != 1) revert NoReservation(); if (recipient == address(0) || recipient == address(this)) revert InvalidRecipient(); seatState[id][msg.sender] = 2; if (!MOBToken(mob.token).transfer(recipient, TOKENS_PER_SEAT)) revert TransferFailed(); emit TokensClaimed(id, msg.sender, recipient); } function getMob(uint256 id) external view returns (Mob memory) { return _mob(id); } function myMobCount(address account) external view returns (uint256) { return accountMobs[account].length; } function getMyPage(uint256 cursor, uint256 limit, address account) external view returns (uint256[] memory ids, Mob[] memory page, uint8[] memory states, uint256 next) { if (limit == 0 || limit > 30) revert InvalidMetadata(); uint256 count = accountMobs[account].length; uint256 end = cursor == 0 || cursor > count ? count : cursor; uint256 length = end < limit ? end : limit; ids = new uint256[](length); page = new Mob[](length); states = new uint8[](length); for (uint256 i; i < length; ++i) { uint256 id = accountMobs[account][end - i - 1]; ids[i] = id; page[i] = mobs[id]; states[i] = seatState[id][account]; } next = end - length; } /// @notice Newest first. Cursor 0 starts at mobCount; next 0 means end. function getPage(uint256 cursor, uint256 limit, address account) external view returns (Mob[] memory page, uint8[] memory states, uint256 next) { if (limit == 0 || limit > 30) revert InvalidMetadata(); uint256 end = cursor == 0 || cursor > mobCount ? mobCount : cursor; uint256 length = end < limit ? end : limit; page = new Mob[](length); states = new uint8[](length); for (uint256 i; i < length; ++i) { page[i] = mobs[end - i]; states[i] = seatState[end - i][account]; } next = end - length; } function _list(uint256 id, address account) private { if (!accountListed[id][account]) { accountListed[id][account] = true; accountMobs[account].push(id); } } function _mob(uint256 id) private view returns (Mob storage mob) { if (id == 0 || id > mobCount) revert UnknownMob(); mob = mobs[id]; } function _launch(uint256 id, Mob storage mob) private { MOBToken token = new MOBToken(mob.name, mob.ticker); address pair = factory.getPair(address(token), address(weth)); if (pair == address(0)) pair = factory.createPair(address(token), address(weth)); // Predicted token addresses can already have a pair with WETH donations. // Nobody can own this new token yet, so existing LP supply is invalid. if (IV2Pair(pair).totalSupply() != 0) revert InvalidPool(); mob.token = address(token); mob.pair = pair; totalEscrow -= SEATS * SEAT_PRICE; weth.deposit{value: SEATS * SEAT_PRICE}(); if (!weth.transfer(pair, SEATS * SEAT_PRICE) || !token.transfer(pair, LIQUIDITY_TOKENS)) revert TransferFailed(); if (IV2Pair(pair).mint(liquidityLock) == 0) revert InvalidPool(); emit MobLaunched(id, address(token), pair); } }