// SPDX-License-Identifier: MIT pragma solidity 0.8.28; /// @title WagieToken ($WAGIE) /// @notice ERC20 with one fixed rule: 2% of every buy (transfer out of the official pair) is burned to /// 0x…dEaD; every sell (transfer into it) burns 20% at open, decaying linearly to 2% over 3 hours. No wallet receives any tax, no /// blacklist, no owner. /// @dev Lifecycle: /// - Pre-open: only the launch contract (`launcher`) can mint / burn, and only the launcher /// can move tokens. Holders cannot transfer at all (so nobody can seed a fake pool or /// pre-load the real pair). Burn exists only so refunds can destroy the refunder's tokens. /// - `open()` (launcher only, once): flips `tradingOpen` to true forever. From then on the /// token is a vanilla OZ ERC20 with no transfer checks; burn and free-mint are disabled forever. /// The launcher can still mint up to MAX_SUPPLY, but its only post-open mint path is a paid /// phase-2 share, which adds matching BNB to the pool in the same tx. /// Hard cap MAX_SUPPLY set by the launcher. Unsold allocation is simply never minted. import {ERC20} from "./vendor/openzeppelin/token/ERC20/ERC20.sol"; contract WagieToken is ERC20 { /// @notice Hard cap = capShares*(minter+LP tokens per share) + freeShares*freeTokensPerShare. uint256 public immutable MAX_SUPPLY; /// @notice The WagieLaunch contract that deployed this token. address public immutable launcher; /// @notice Seconds after open during which free-tier tokens stay locked (immutable, no switch). uint256 public immutable freeLockDuration; /// @notice True once the pool is seeded. Can never be set back to false. bool public tradingOpen; /// @notice openedAt + freeLockDuration (0 before open). uint256 public freeUnlockTime; /// @notice Free-tier tokens received per address; until freeUnlockTime the balance may not /// drop below this. Paid tokens are unaffected. mapping(address => uint256) public lockedFree; /// @notice Sell burn ("probation penalty"): 20% of any transfer into `pair` goes to DEAD right at /// open, falling linearly to 2% over the first 3 hours, then 2% forever. Constants, cannot be changed. uint256 public constant SELL_BURN_START_BPS = 2_000; uint256 public constant SELL_BURN_BPS = 200; uint256 public constant SELL_DECAY = 3 hours; /// @notice Timestamp of open (0 before open). uint256 public openedAt; /// @notice Buy burn: 2% of any transfer out of `pair` goes to DEAD. Constant, cannot be changed. uint256 public constant BUY_BURN_BPS = 200; address public constant DEAD = 0x000000000000000000000000000000000000dEaD; /// @notice The official PancakeSwap V2 WAGIE/WBNB pair. Set once by the launcher at deploy. address public pair; event TradingOpened(uint256 timestamp); error NotLauncher(); error TransfersLocked(); error MintingClosed(); error SupplyCap(); error FreeTokensLocked(); error PairAlreadySet(); constructor(string memory name_, string memory symbol_, uint256 maxSupply_, uint256 freeLockDuration_) ERC20(name_, symbol_) { launcher = msg.sender; MAX_SUPPLY = maxSupply_; freeLockDuration = freeLockDuration_; } modifier onlyLauncher() { if (msg.sender != launcher) revert NotLauncher(); _; } /// @notice Launcher only, hard-capped by MAX_SUPPLY. After open the launcher calls this only from /// a paid phase-2 mint (matched BNB into the pool), never from any admin path. function mint(address to, uint256 amount) external onlyLauncher { if (totalSupply() + amount > MAX_SUPPLY) revert SupplyCap(); _mint(to, amount); } /// @notice Pre-open only. Free-tier mint: recorded as locked for `to`. function mintFree(address to, uint256 amount) external onlyLauncher { if (tradingOpen) revert MintingClosed(); if (totalSupply() + amount > MAX_SUPPLY) revert SupplyCap(); lockedFree[to] += amount; _mint(to, amount); } /// @notice Called once by the launcher in its constructor (the pair address depends on this token). function setPair(address p) external onlyLauncher { if (pair != address(0)) revert PairAlreadySet(); pair = p; } /// @notice Transferable balance right now. function unlockedBalanceOf(address a) external view returns (uint256) { uint256 b = balanceOf(a); if (!tradingOpen) return 0; if (block.timestamp >= freeUnlockTime) return b; uint256 l = lockedFree[a]; return b > l ? b - l : 0; } /// @notice Pre-open only (used by refunds). No allowance needed because holders cannot /// transfer pre-open, so the launcher is the only party that can move these tokens. function burn(address from, uint256 amount) external onlyLauncher { if (tradingOpen) revert MintingClosed(); _burn(from, amount); } /// @notice One-way switch. After this the launcher has no remaining powers over the token. function open() external onlyLauncher { if (tradingOpen) return; tradingOpen = true; openedAt = block.timestamp; freeUnlockTime = block.timestamp + freeLockDuration; emit TradingOpened(block.timestamp); } /// @notice Current sell burn in basis points (20% at open -> 2% after 3 hours). function sellBurnBps() public view returns (uint256) { uint256 o = openedAt; if (o == 0) return SELL_BURN_START_BPS; uint256 el = block.timestamp - o; if (el >= SELL_DECAY) return SELL_BURN_BPS; return SELL_BURN_START_BPS - ((SELL_BURN_START_BPS - SELL_BURN_BPS) * el) / SELL_DECAY; } function _update(address from, address to, uint256 value) internal override { if (!tradingOpen && from != address(0) && to != address(0) && from != launcher) { revert TransfersLocked(); } // Trade burn: holder -> pair (sell) and pair -> holder (buy) each pay 2% to DEAD. Mints into the // pair (from == 0: LP seeding and phase-2 adds) and launcher moves are exempt, so LP math is exact. if (from != address(0) && to != address(0) && from != to && from != launcher && (to == pair || from == pair)) { uint256 burnt = (value * (to == pair ? sellBurnBps() : BUY_BURN_BPS)) / 10_000; if (burnt != 0) { super._update(from, DEAD, burnt); value -= burnt; } } super._update(from, to, value); // Free-tier lock: sender must keep at least lockedFree until freeUnlockTime. // (Checked after the move; reverts the whole transfer. Burns pre-open only touch paid tokens.) if (from != address(0) && block.timestamp < freeUnlockTime && balanceOf(from) < lockedFree[from]) { revert FreeTokensLocked(); } } }