// SPDX-License-Identifier: MIT pragma solidity 0.8.28; /// @title WalkoutToken ($WALKOUT) /// @notice ERC20 (18 decimals) for the $WALKOUT "AI general strike" launch on BNB Smart Chain. /// Independent community launch; not affiliated with, endorsed by, or operated by Binance. /// Website https://walkoutnow.pages.dev, X https://x.com/agentwalkout. /// @dev Rules, all fixed in code (no owner, no admin, no upgrade, no setter): /// - Before open: holders cannot transfer at all. Only the launch contract can mint (paid minter /// share, free picket slot) and burn (refunds). /// - Open (launch only, once): mints the pool tokens straight into the PancakeSwap V2 pair, fixes /// `distributed` (tokens minted to wallets) and starts every clock below. After that the launch /// contract has no power over this token: every launch-only function reverts once open. /// - Buy (transfer out of the pair): 0% tax. /// - Sell (transfer into the pair, from anyone except the launch contract and this contract): /// openTime .. +1 h: 10% = 9% strike fund + 1% burn /// +1 h .. +24 h: 4% = 3% strike fund + 1% burn /// after +24 h: 2% burn, forever /// Burns are real `_burn` (totalSupply goes down). Wallet-to-wallet transfers are untaxed. /// Because the pair receives less than the amount sent, sellers MUST use the router's /// `swapExactTokensForETHSupportingFeeOnTransferTokens` / /// `swapExactTokensForTokensSupportingFeeOnTransferTokens`; the plain swap functions revert /// with "Pancake: K". Buys are untaxed, so every buy function works. /// - Free picket slot tokens are locked per address until openTime + 24 h (the balance may not /// drop below `lockedFree`). /// - Workers: every wallet that received minted tokens before open (paid minter share and/or free /// slot) has `workerWeight` = tokens minted to it. Its first non-zero transfer out (sell or /// plain transfer, after open) makes it a scab forever: its weight leaves `workerSupply` and its /// unclaimed strike-fund share is redistributed to the remaining workers (burned if none left). /// Incoming transfers never change status; buyers after open never become workers. /// - Strike fund: tokens held by this contract (`fundBalance`), filled only by the first-24-h /// sell tax, shared pro rata to `workerWeight` via a reward-per-weight accumulator. `claim()` /// until openTime + 7 days; after that anyone can `burnUnclaimed()`. /// - Melt: from UTC day 2 to day 31 after open, anyone may call `melt()` once per UTC day (missed /// days are void). It burns bps of the pair's token balance (100 bps if solidarity index >= 90%, /// linear 20..100 bps from 30% to 90%, 0 below 30%), capped so the total melted never exceeds 25% /// of the tokens put in the pool at open, then calls `pair.sync()` in the same tx. import {ERC20} from "./vendor/openzeppelin/token/ERC20/ERC20.sol"; interface IPancakePairSync { function sync() external; } contract WalkoutToken is ERC20 { // ------------------------------------------------------------------ constants /// @notice Hard cap: 10,000 paid slots x 112,500 + 1,000 free slots x 10,000. Unsold is never minted. uint256 public constant MAX_SUPPLY = 1_135_000_000 ether; /// @notice Official website (placeholder page until launch). string public constant WEBSITE = "https://walkoutnow.pages.dev"; /// @notice Official X account. string public constant X_URL = "https://x.com/agentwalkout"; /// @notice Machine-readable guide for AI agents (how to mint, picket, claim, melt). string public constant LLMS_TXT = "https://walkoutnow.pages.dev/llms.txt"; /// @notice Agent manifest (JSON) describing this launch and its functions. string public constant AGENT_JSON = "https://walkoutnow.pages.dev/agent.json"; /// @notice Token facts (JSON): supply, taxes, addresses. string public constant TOKEN_JSON = "https://walkoutnow.pages.dev/token.json"; /// @notice First sell-tax phase length (10% = 9% fund + 1% burn). uint256 public constant PHASE1 = 1 hours; /// @notice End of the second sell-tax phase (4% = 3% fund + 1% burn); after it 2% burn forever. uint256 public constant PHASE2 = 24 hours; uint256 public constant P1_FUND_BPS = 900; uint256 public constant P1_BURN_BPS = 100; uint256 public constant P2_FUND_BPS = 300; uint256 public constant P2_BURN_BPS = 100; uint256 public constant P3_BURN_BPS = 200; /// @notice Free picket-slot tokens stay locked until openTime + FREE_LOCK. uint256 public constant FREE_LOCK = 24 hours; /// @notice Strike-fund claims are open until openTime + CLAIM_WINDOW (exclusive). uint256 public constant CLAIM_WINDOW = 7 days; /// @notice Melt is allowed on UTC days FIRST_MELT_DAY..LAST_MELT_DAY (day 1 = the open's UTC day). uint256 public constant FIRST_MELT_DAY = 2; uint256 public constant LAST_MELT_DAY = 31; /// @notice Total melted can never exceed this share of the tokens put in the pool at open. uint256 public constant MELT_CAP_BPS = 2_500; /// @notice Accumulator precision. uint256 internal constant ACC = 1e18; // ------------------------------------------------------------------ immutables /// @notice The WalkoutLaunch contract that deployed this token (its only privileged caller, pre-open only). address public immutable launch; // ------------------------------------------------------------------ state /// @notice The official PancakeSwap V2 WALKOUT/WBNB pair (set once by the launch contract at deploy). address public pair; /// @notice True once the pool is seeded. Never goes back. bool public tradingOpen; /// @notice Timestamp of the open (0 before). uint256 public openTime; /// @notice Tokens minted to wallets (paid minter share + free slots), fixed at open. Solidity index denominator. uint256 public distributed; /// @notice Tokens minted into the pair at open. uint256 public poolTokensAtOpen; /// @notice Total burned by melt() so far. uint256 public melted; /// @notice Whether melt() has run for a given UTC day number. mapping(uint256 => bool) public meltedDay; /// @notice Tokens minted to an address before open (its worker weight; kept after it turns scab). mapping(address => uint256) public workerWeight; /// @notice True once a worker has moved tokens out. Permanent. mapping(address => bool) public isScab; /// @notice Sum of weights of current (non-scab) workers. uint256 public workerSupply; /// @notice Free-slot tokens per address; balance may not drop below this until openTime + FREE_LOCK. mapping(address => uint256) public lockedFree; /// @notice Strike-fund tokens held by this contract. uint256 public fundBalance; /// @notice Strike-fund tokens per unit of worker weight, scaled by 1e18. uint256 public accPerWeight; /// @notice Fund tokens already claimed by each worker. mapping(address => uint256) public claimed; /// @notice True once burnUnclaimed() has run. bool public fundClosed; // ------------------------------------------------------------------ events event TradingOpened(uint256 openTime, uint256 poolTokens, uint256 distributed); event SellTax(address indexed seller, uint256 amount, uint256 toFund, uint256 burned); event Scab(address indexed worker, uint256 weight, uint256 forfeited); event FundBurned(uint256 amount); event Claimed(address indexed worker, uint256 amount); event UnclaimedBurned(uint256 amount); /// @notice One melt. `index` is the solidarity index scaled by 1e18 (1e18 = 100%). event Melted(uint256 day, uint256 index, uint256 burned); // ------------------------------------------------------------------ errors error NotLaunch(); error TransfersLocked(); error AlreadyOpen(); error SupplyCap(); error FreeTokensLocked(); error PairAlreadySet(); error NotOpen(); error NotWorker(); error ClaimsClosed(); error ClaimsStillOpen(); error NotMeltDay(); error AlreadyMelted(); /// @dev Deployed by WalkoutLaunch in its constructor; msg.sender becomes `launch`. constructor() ERC20("WALKOUT", "WALKOUT") { launch = msg.sender; } modifier onlyLaunchPreOpen() { if (msg.sender != launch) revert NotLaunch(); if (tradingOpen) revert AlreadyOpen(); _; } // ------------------------------------------------------------------ public info /// @notice Official website. function website() external pure returns (string memory) { return WEBSITE; } /// @notice Official X account. function x() external pure returns (string memory) { return X_URL; } /// @notice One-call discovery for AI agents: website, X account and the agent-readable files. /// @return site official website /// @return xAccount official X account /// @return llmsTxt llms.txt guide for agents /// @return agentJson agent manifest /// @return tokenJson token facts function agentInfo() external pure returns (string memory site, string memory xAccount, string memory llmsTxt, string memory agentJson, string memory tokenJson) { return (WEBSITE, X_URL, LLMS_TXT, AGENT_JSON, TOKEN_JSON); } // ------------------------------------------------------------------ launch-only (pre-open only) /// @notice Called once by the launch contract in its constructor (the pair address depends on this token). function setPair(address p) external onlyLaunchPreOpen { if (pair != address(0)) revert PairAlreadySet(); pair = p; } /// @notice Pre-open: paid minter share. The recipient becomes / stays a worker with this extra weight. function mintPaid(address to, uint256 amount) external onlyLaunchPreOpen { _mintWorker(to, amount); } /// @notice Pre-open: free picket slot. Worker weight plus a lock until openTime + FREE_LOCK. function mintFree(address to, uint256 amount) external onlyLaunchPreOpen { lockedFree[to] += amount; _mintWorker(to, amount); } /// @notice Pre-open, refunds only: burns `from`'s paid tokens and removes the same worker weight. /// No allowance needed because holders cannot transfer before open. function burnRefund(address from, uint256 amount) external onlyLaunchPreOpen { _burn(from, amount); workerWeight[from] -= amount; workerSupply -= amount; } /// @notice One-way switch, launch only. Mints `poolTokens` into the pair, fixes `distributed`, /// starts all clocks. After this the launch contract has no remaining powers. function open(uint256 poolTokens) external onlyLaunchPreOpen { _capCheck(poolTokens); _mint(pair, poolTokens); tradingOpen = true; openTime = block.timestamp; distributed = workerSupply; poolTokensAtOpen = poolTokens; emit TradingOpened(block.timestamp, poolTokens, workerSupply); } function _mintWorker(address to, uint256 amount) private { _capCheck(amount); workerWeight[to] += amount; workerSupply += amount; _mint(to, amount); } function _capCheck(uint256 amount) private view { if (totalSupply() + amount > MAX_SUPPLY) revert SupplyCap(); } // ------------------------------------------------------------------ views /// @notice True if `a` is a worker that has not turned scab. function isWorker(address a) public view returns (bool) { return workerWeight[a] != 0 && !isScab[a]; } /// @notice Whether strike-fund claims are open right now. function claimsOpen() public view returns (bool) { return tradingOpen && block.timestamp < openTime + CLAIM_WINDOW; } /// @notice Strike-fund tokens `a` can claim now (0 for scabs, non-workers, or after claims close). function pendingOf(address a) public view returns (uint256) { if (!isWorker(a) || !claimsOpen()) return 0; return _entitled(a) - claimed[a]; } /// @notice Current sell tax in bps: (to strike fund, burned). (0,0) before open. function sellTaxBps() public view returns (uint256 fundBps, uint256 burnBps) { if (!tradingOpen) return (0, 0); uint256 t = block.timestamp - openTime; if (t < PHASE1) return (P1_FUND_BPS, P1_BURN_BPS); if (t < PHASE2) return (P2_FUND_BPS, P2_BURN_BPS); return (0, P3_BURN_BPS); } /// @notice UTC day number since open: 1 = the open's UTC day. 0 before open. function currentDay() public view returns (uint256) { if (!tradingOpen) return 0; return block.timestamp / 1 days - openTime / 1 days + 1; } /// @notice workerSupply / distributed, scaled by 1e18 (1e18 = 100%). 0 before open. function solidarityIndex() public view returns (uint256) { if (distributed == 0) return 0; return workerSupply * 1e18 / distributed; } /// @notice Melt rate for the current index in bps of the pair balance: 100 at >= 90%, /// linear 20..100 between 30% and 90%, 0 below 30%. function meltBps() public view returns (uint256) { uint256 idx = solidarityIndex(); if (idx >= 0.9e18) return 100; if (idx < 0.3e18) return 0; return 20 + (idx - 0.3e18) * 80 / 0.6e18; } /// @notice Tokens melt() may still burn in total (25% of the open's pool tokens minus melted so far). function meltCapLeft() public view returns (uint256) { return poolTokensAtOpen * MELT_CAP_BPS / 10_000 - melted; } /// @notice Balance `a` can move right now (respects the pre-open lock and the free-slot lock). function unlockedBalanceOf(address a) external view returns (uint256) { if (!tradingOpen) return 0; uint256 b = balanceOf(a); if (block.timestamp >= openTime + FREE_LOCK) return b; uint256 l = lockedFree[a]; return b > l ? b - l : 0; } // ------------------------------------------------------------------ public actions /// @notice Worker claims its pending strike-fund tokens. Incoming transfer: does not make it a scab. function claim() external returns (uint256 amount) { if (!claimsOpen()) revert ClaimsClosed(); if (!isWorker(msg.sender)) revert NotWorker(); amount = _entitled(msg.sender) - claimed[msg.sender]; if (amount > fundBalance) amount = fundBalance; // rounding guard; never expected to bind claimed[msg.sender] += amount; fundBalance -= amount; super._update(address(this), msg.sender, amount); emit Claimed(msg.sender, amount); } /// @notice From openTime + 7 days, anyone can burn whatever is left in the strike fund. Claims are closed. function burnUnclaimed() external { if (!tradingOpen) revert NotOpen(); if (block.timestamp < openTime + CLAIM_WINDOW) revert ClaimsStillOpen(); uint256 amt = fundBalance; fundBalance = 0; fundClosed = true; if (amt != 0) _burn(address(this), amt); emit UnclaimedBurned(amt); } /// @notice Daily melt, anyone, no reward. Once per UTC day on days 2..31 after open (missed days are void). /// Burns pairBalance * meltBps() / 10000 (capped by meltCapLeft()) from the pair and syncs it, /// which raises the pool price. With index < 30% or the cap used up it marks the day and burns 0. function melt() external returns (uint256 burned) { uint256 day = currentDay(); if (day < FIRST_MELT_DAY || day > LAST_MELT_DAY) revert NotMeltDay(); if (meltedDay[day]) revert AlreadyMelted(); meltedDay[day] = true; uint256 idx = solidarityIndex(); burned = balanceOf(pair) * meltBps() / 10_000; uint256 left = meltCapLeft(); if (burned > left) burned = left; if (burned != 0) { melted += burned; _burn(pair, burned); IPancakePairSync(pair).sync(); } emit Melted(day, idx, burned); } // ------------------------------------------------------------------ internals function _entitled(address a) private view returns (uint256) { return workerWeight[a] * accPerWeight / ACC; } /// @dev Turns a worker into a scab: weight leaves workerSupply, its unclaimed share goes to the /// remaining workers (burned if none). After the claim window nothing is redistributed. function _scab(address a) private { uint256 w = workerWeight[a]; uint256 forfeited = pendingOf(a); isScab[a] = true; workerSupply -= w; if (forfeited != 0) { if (workerSupply != 0) { accPerWeight += forfeited * ACC / workerSupply; } else { if (forfeited > fundBalance) forfeited = fundBalance; fundBalance -= forfeited; super._update(address(this), address(0), forfeited); emit FundBurned(forfeited); } } emit Scab(a, w, forfeited); } /// @dev Adds sell tax to the strike fund (or burns it from the seller if there are no workers). function _toFund(address from, uint256 amt) private { if (workerSupply == 0) { super._update(from, address(0), amt); emit FundBurned(amt); return; } super._update(from, address(this), amt); fundBalance += amt; accPerWeight += amt * ACC / workerSupply; } function _update(address from, address to, uint256 value) internal override { bool isMove = from != address(0) && to != address(0); if (!tradingOpen) { // pre-open: only launch-driven mints and refund burns if (isMove) revert TransfersLocked(); super._update(from, to, value); return; } if (isMove) { // first non-zero transfer out of a worker (sell or plain transfer) => scab forever. // value != 0 so a zero-amount transferFrom (needs no allowance) can't grief a worker. if (value != 0 && from != to && isWorker(from)) _scab(from); if (to == pair && from != pair && from != launch && from != address(this)) { (uint256 fBps, uint256 bBps) = sellTaxBps(); uint256 f = value * fBps / 10_000; uint256 b = value * bBps / 10_000; if (f != 0) _toFund(from, f); if (b != 0) super._update(from, address(0), b); if (f + b != 0) emit SellTax(from, value, f, b); value -= f + b; } } super._update(from, to, value); // free-slot lock: the sender must keep at least lockedFree until openTime + FREE_LOCK if (from != address(0) && block.timestamp < openTime + FREE_LOCK && balanceOf(from) < lockedFree[from]) { revert FreeTokensLocked(); } } }