// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.28; import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {LaunchToken} from "./LaunchToken.sol"; import {RefundLauncher} from "./RefundLauncher.sol"; import {TickMath} from "./vendor/uniswap/v4-core/TickMath.sol"; import {LiquidityAmounts} from "./vendor/uniswap/v4-core/LiquidityAmounts.sol"; import {UniversalRewardsDistributor} from "./vendor/morpho/UniversalRewardsDistributor.sol"; import {MerkleDistributor} from "./vendor/uniswap/MerkleDistributor.sol"; import { PoolKey, ExactInputSingleParams, IPoolManager, IPositionManager, IPermit2, IUniversalRouter, IWETH9 } from "./interfaces/IUniswapV4.sol"; /// @title PaperLaunchpad (papertax.xyz), TESTNET PROTOTYPE, NOT AUDITED /// @notice Token launches straight into a locked Uniswap V4 pool, paying $TAX stakers and PAPER stakers. /// /// - Every launch mints a fixed 1B supply: 90% goes into a Uniswap V4 pool against native HYPE with a 2% fee and no /// hooks, as one single-sided position starting at `startTick`, so anyone can buy from the first block. 5% is for /// PAPER stakers and 5% for $TAX stakers, released 7 days after launch: the PAPER stakers' 5% in equal shares /// through its own Uniswap MerkleDistributor (every wallet with 1,000+ PAPER staked at both launch and release), the /// $TAX stakers' 5% pro rata through the rewards distributor. /// - The creator can buy in the same transaction as the launch (through Uniswap's Universal Router), before anyone /// else can trade. /// - The launchpad keeps every position NFT and never removes liquidity: the only position action it takes is a /// zero-liquidity decrease, which collects fees. Pool liquidity is locked forever. Anyone can collect a pool's fees /// (in HYPE and in the token); each is split 30% PaperTax, 40% creator and 30% $TAX stakers. PaperTax and creator /// HYPE is paid out on request; tokens are sent straight away. /// - $TAX stakers' rewards (their fee share, HYPE wrapped as WHYPE, and 5% drops) go to Morpho's Universal Rewards /// Distributor, created here with the deployer as its owner. Who gets what is worked out off chain and published /// there as a Merkle root, behind the distributor's timelock; per-token totals sent are recorded here. /// - $TAX (once, owner only): 50% airdropped equally through Uniswap's MerkleDistributor, 40% in its own locked pool, /// 10% to the dev wallet through an OpenZeppelin VestingWallet (90-day cliff, then 270 days linear). /// /// Off-the-shelf parts: OpenZeppelin ERC20, Ownable2Step, ReentrancyGuard, SafeERC20, Address, VestingWallet; /// Uniswap V4 (pools, positions, Universal Router, Permit2), Uniswap's MerkleDistributor, Morpho's Universal Rewards /// Distributor and Uniswap's TickMath and LiquidityAmounts. Custom here: opening the pool and position, collecting and /// splitting fees, releasing drops. contract PaperLaunchpad is Ownable2Step, ReentrancyGuard { using SafeERC20 for IERC20; // ----- fixed parameters ----- uint256 public constant SUPPLY = 1_000_000_000 ether; uint256 public constant POOL_BPS = 9000; uint256 public constant PAPER_DROP_BPS = 500; uint256 public constant REFUND_DROP_BPS = 500; uint256 public constant DROP_DELAY = 7 days; uint256 public constant PLATFORM_SHARE_BPS = 3000; uint256 public constant CREATOR_SHARE_BPS = 4000; // the rest, 30%, to $TAX stakers uint24 public constant POOL_FEE = 20_000; // 2% (Uniswap V4 fees are in hundredths of a basis point) int24 public constant TICK_SPACING = 200; int24 public constant MAX_TICK = 887_200; // the largest multiple of 200 inside Uniswap's range int24 public constant MAX_START_TICK = 340_000; // keeps a wide price range below every start /// v4-core's per-tick liquidity cap for tick spacing 200 (Pool.tickSpacingToMaxLiquidityPerTick). uint128 private constant MAX_LIQUIDITY = uint128(type(uint128).max / 8873); // Uniswap V4 action and command codes (v4-periphery Actions, Universal Router Commands) uint8 private constant MINT_POSITION = 0x02; uint8 private constant DECREASE_LIQUIDITY = 0x01; uint8 private constant SETTLE_PAIR = 0x0d; uint8 private constant TAKE_PAIR = 0x11; uint8 private constant SWAP_EXACT_IN_SINGLE = 0x06; uint8 private constant SETTLE_ALL = 0x0c; uint8 private constant TAKE_ALL = 0x0f; uint8 private constant V4_SWAP = 0x10; bytes32 private constant POOLS_SLOT = bytes32(uint256(6)); // v4-core StateLibrary.POOLS_SLOT // $TAX uint256 public constant PLATFORM_POOL_BPS = 4000; uint256 public constant PLATFORM_DEV_BPS = 1000; uint256 public constant PLATFORM_TRADERS_BPS = 200; // early PaperTax traders: held here, released once as a Merkle drop uint256 public constant DEV_CLIFF = 90 days; uint256 public constant DEV_VESTING = 270 days; // ----- set at deploy ----- address public immutable platform; // receives PaperTax's fee share IPositionManager public immutable positions; IPoolManager public immutable poolManager; IPermit2 public immutable permit2; IUniversalRouter public immutable router; IWETH9 public immutable whype; // only for paying stakers' HYPE through the rewards distributor /// Starting price of every ordinary launch, as the Uniswap tick of HYPE per token. int24 public immutable startTick; uint256 public immutable launchFee; address public immutable rewards; // Morpho Universal Rewards Distributor address public immutable refundLauncher; struct Launch { address creator; uint64 launchedAt; uint64 dropsReleasedAt; // 0 until the two 5% drops are released int24 topTick; // the position covers every price from Uniswap's lowest up to this (the start) bytes32 poolId; uint256 positionId; // Uniswap V4 position NFT, held here forever uint256 creatorFees; // HYPE owed to the creator } mapping(address => Launch) public launches; address[] public tokens; uint256 public platformFees; // HYPE owed to PaperTax /// Totals sent to the rewards distributor for $TAX stakers, per reward token (WHYPE and launched tokens). mapping(address => uint256) public refundStakerRewards; /// Each token's PAPER-staker drop: a Uniswap MerkleDistributor paying `paperDropPerWallet` to each listed wallet. mapping(address => address) public paperDropDistributor; mapping(address => uint256) public paperDropPerWallet; mapping(address => uint256) public paperDropEligible; // $TAX, launched once address public platformToken; address public platformVesting; address public refundDistributor; address public refundStaking; uint256 public refundPerWallet; uint256 public refundEligible; uint256 public refundMinStake; uint256 public refundSnapshotBlock; uint256 public traderDrop; // $TAX held for early PaperTax traders until releaseTraderDrop address public traderDistributor; event Launched(address indexed token, address indexed creator, string name, string symbol, string meta, bytes32 poolId, uint256 positionId); event CreatorBuy(address indexed token, address indexed creator, uint256 hypeIn, uint256 tokensOut); event FeesCollected(address indexed token, uint256 hype, uint256 tokenAmount, uint256 toStakersHype, uint256 toStakersToken); event DropsReleased(address indexed token, address paperDistributor, uint256 paperEligible, uint256 perWallet, uint256 refundStakers); event CreatorChanged(address indexed token, address indexed from, address indexed to); event CreatorFeesPaid(address indexed token, address indexed creator, uint256 amount); event PlatformFeesPaid(address indexed platform, uint256 amount); event TraderDropReleased(address distributor, bytes32 root, uint256 amount); event PlatformTokenLaunched(address indexed token, address distributor, address staking, address vesting, uint256 eligible, uint256 perWallet, uint256 minStake, uint256 snapshotBlock); constructor( address platform_, IPositionManager positions_, IUniversalRouter router_, IWETH9 whype_, int24 startTick_, uint256 launchFee_, uint256 rewardsTimelock ) Ownable(msg.sender) { require(platform_ != address(0) && address(whype_) != address(0), "address"); _checkTick(startTick_); platform = platform_; positions = positions_; poolManager = positions_.poolManager(); permit2 = positions_.permit2(); router = router_; whype = whype_; startTick = startTick_; launchFee = launchFee_; rewards = address(new UniversalRewardsDistributor(msg.sender, rewardsTimelock, bytes32(0), bytes32(0))); refundLauncher = address(new RefundLauncher()); } /// HYPE arrives only from the pool manager (collected fees). receive() external payable { require(msg.sender == address(poolManager), "no direct HYPE"); } // ================================================================================= launch /// @notice Launch a token. Send the launch fee, plus any HYPE you want to buy with in the same transaction. function createToken(string calldata name, string calldata symbol, string calldata meta) external payable nonReentrant returns (address token) { require(bytes(name).length > 0 && bytes(name).length <= 32, "name"); require(bytes(symbol).length > 0 && bytes(symbol).length <= 10, "symbol"); require(bytes(meta).length <= 400, "meta"); require(msg.value >= launchFee, "launch fee"); // The salt changes every block, so a pool someone sets up early for a predicted address can only delay a launch. bytes32 salt = keccak256(abi.encode(msg.sender, tokens.length, block.number)); token = address(new LaunchToken{salt: salt}(name, symbol, SUPPLY, address(this))); (bytes32 poolId, uint256 positionId, int24 top) = _openPool(token, (SUPPLY * POOL_BPS) / 10_000, startTick); launches[token] = Launch(msg.sender, uint64(block.timestamp), 0, top, poolId, positionId, 0); tokens.push(token); platformFees += launchFee; emit Launched(token, msg.sender, name, symbol, meta, poolId, positionId); uint256 buy = msg.value - launchFee; if (buy > 0) { // The pool was opened in this transaction, so nobody can trade ahead of this buy: no minimum needed. uint256 before = IERC20(token).balanceOf(address(this)); bytes[] memory p = new bytes[](3); p[0] = abi.encode(ExactInputSingleParams(_key(token), true, SafeCast.toUint128(buy), 0, 0, "")); p[1] = abi.encode(address(0), buy); // SETTLE_ALL: pay up to `buy` HYPE p[2] = abi.encode(token, uint256(0)); // TAKE_ALL: receive the tokens here bytes[] memory inputs = new bytes[](1); inputs[0] = abi.encode(abi.encodePacked(SWAP_EXACT_IN_SINGLE, SETTLE_ALL, TAKE_ALL), p); router.execute{value: buy}(abi.encodePacked(V4_SWAP), inputs, block.timestamp); uint256 out = IERC20(token).balanceOf(address(this)) - before; IERC20(token).safeTransfer(msg.sender, out); emit CreatorBuy(token, msg.sender, buy, out); } } // =================================================================================== fees /// @notice Collect a pool's trading fees and split them. Anyone can call this. function collectFees(address token) external nonReentrant { Launch storage L = _launch(token); uint256 hype0 = address(this).balance; uint256 tok0 = IERC20(token).balanceOf(address(this)); // A zero-liquidity decrease collects the position's fees; TAKE_PAIR sends both currencies here. bytes[] memory p = new bytes[](2); p[0] = abi.encode(L.positionId, uint256(0), uint128(0), uint128(0), bytes("")); p[1] = abi.encode(address(0), token, address(this)); positions.modifyLiquidities(abi.encode(abi.encodePacked(DECREASE_LIQUIDITY, TAKE_PAIR), p), block.timestamp); uint256 hype = address(this).balance - hype0; uint256 amt = IERC20(token).balanceOf(address(this)) - tok0; uint256 sh; if (hype > 0) { sh = (hype * (10_000 - PLATFORM_SHARE_BPS - CREATOR_SHARE_BPS)) / 10_000; uint256 c = (hype * CREATOR_SHARE_BPS) / 10_000; refundStakerRewards[address(whype)] += sh; L.creatorFees += c; platformFees += hype - sh - c; // PaperTax gets the rounding, never less than its 30% if (sh > 0) { whype.deposit{value: sh}(); require(whype.transfer(rewards, sh), "transfer"); } } uint256 st; if (amt > 0) { st = (amt * (10_000 - PLATFORM_SHARE_BPS - CREATOR_SHARE_BPS)) / 10_000; uint256 c = (amt * CREATOR_SHARE_BPS) / 10_000; refundStakerRewards[token] += st; IERC20(token).safeTransfer(rewards, st); IERC20(token).safeTransfer(L.creator, c); IERC20(token).safeTransfer(platform, amt - st - c); } emit FeesCollected(token, hype, amt, sh, st); } /// @notice Move a token's creator share (fees, unpaid HYPE included) to another wallet. Only the creator can. function setCreator(address token, address to) external { Launch storage L = _launch(token); require(msg.sender == L.creator && to != address(0) && to != address(this), "creator"); emit CreatorChanged(token, L.creator, to); L.creator = to; } /// @notice Pay a creator their HYPE fees. Anyone can call it; it only ever pays the creator. function payCreator(address token) external nonReentrant { Launch storage L = _launch(token); uint256 a = L.creatorFees; L.creatorFees = 0; emit CreatorFeesPaid(token, L.creator, a); _send(L.creator, a); } function payPlatform() external nonReentrant { uint256 a = platformFees; platformFees = 0; emit PlatformFeesPaid(platform, a); _send(platform, a); } // ================================================================================== drops /// @notice Release a token's two 5% drops, 7 days after launch (owner only, as it publishes the PAPER list). /// `paperRoot` is a Uniswap MerkleDistributor tree giving each of the `paperEligible` wallets (1,000+ PAPER staked /// at both launch and release) an equal share; rounding dust is burned. If no wallet qualifies, that 5% goes to /// $TAX stakers too. The $TAX stakers' 5% goes to the rewards distributor. function releaseDrops(address token, bytes32 paperRoot, uint256 paperEligible) external onlyOwner nonReentrant { Launch storage L = _launch(token); require(token != platformToken, "no drops"); require(L.dropsReleasedAt == 0, "released"); require(block.timestamp >= L.launchedAt + DROP_DELAY, "too early"); L.dropsReleasedAt = uint64(block.timestamp); uint256 p = (SUPPLY * PAPER_DROP_BPS) / 10_000; uint256 r = (SUPPLY * REFUND_DROP_BPS) / 10_000; address dist; uint256 perWallet; if (paperEligible > 0) { require(paperRoot != bytes32(0), "paper list"); perWallet = p / paperEligible; require(perWallet > 0, "paper list"); dist = address(new MerkleDistributor(token, paperRoot)); paperDropDistributor[token] = dist; paperDropPerWallet[token] = perWallet; paperDropEligible[token] = paperEligible; IERC20(token).safeTransfer(dist, perWallet * paperEligible); uint256 dust = p - perWallet * paperEligible; if (dust > 0) LaunchToken(token).burn(dust); } else { r += p; // nobody qualified: $TAX stakers get it } refundStakerRewards[token] += r; emit DropsReleased(token, dist, paperEligible, perWallet, r); IERC20(token).safeTransfer(rewards, r); } // ================================================================================= $TAX /// @notice Launch $TAX (once, owner only). `root` is a Uniswap MerkleDistributor tree giving each of the /// `eligible` wallets (every wallet with at least `minStake` PAPER staked at mainnet block `snapshotBlock`) an equal /// share of 48% of the supply. 40% goes into its own locked pool starting at `startTick_`; 10% vests to `devWallet`; /// 2% stays here for early PaperTax traders (releaseTraderDrop). function launchPlatformToken( string calldata name, string calldata symbol, uint256 supply, bytes32 root, uint256 eligible, uint256 minStake, uint64 snapshotBlock, address devWallet, int24 startTick_ ) external onlyOwner nonReentrant returns (address token) { require(platformToken == address(0), "platform token already launched"); require(bytes(name).length > 0 && bytes(name).length <= 32, "name"); require(bytes(symbol).length > 0 && bytes(symbol).length <= 10, "symbol"); require(devWallet != address(0) && devWallet != address(this), "dev wallet"); require(supply >= 1e24 && supply <= 1e36, "supply"); // 1M to 1e18 whole tokens require(minStake > 0 && minStake % 1 ether == 0 && eligible > 0, "snapshot"); // whole PAPER, as the list is built _checkTick(startTick_); uint256 pool = (supply * PLATFORM_POOL_BPS) / 10_000; uint256 dev = (supply * PLATFORM_DEV_BPS) / 10_000; uint256 traders = (supply * PLATFORM_TRADERS_BPS) / 10_000; address vesting; address staking; address distributor; uint256 perWallet; (token, distributor, vesting, staking, perWallet) = RefundLauncher(refundLauncher).deploy( name, symbol, supply, root, eligible, pool + dev + traders, devWallet, SafeCast.toUint64(block.timestamp + DEV_CLIFF), SafeCast.toUint64(DEV_VESTING) ); platformToken = token; platformVesting = vesting; refundDistributor = distributor; refundStaking = staking; refundPerWallet = perWallet; refundEligible = eligible; refundMinStake = minStake; refundSnapshotBlock = snapshotBlock; traderDrop = traders; IERC20(token).safeTransfer(vesting, dev); (bytes32 poolId, uint256 positionId, int24 top) = _openPool(token, pool, startTick_); launches[token] = Launch(platform, uint64(block.timestamp), 0, top, poolId, positionId, 0); tokens.push(token); emit Launched(token, platform, name, symbol, "", poolId, positionId); emit PlatformTokenLaunched(token, distributor, staking, vesting, eligible, perWallet, minStake, snapshotBlock); } /// @notice Release the early traders' 2% (once, owner only) to a Uniswap MerkleDistributor for `root`. The $TAX can /// only go to that drop; the list behind the root is published so anyone can check it. function releaseTraderDrop(bytes32 root) external onlyOwner nonReentrant { uint256 a = traderDrop; require(a > 0, "nothing to release"); // before $TAX launches, or already released require(root != bytes32(0), "root"); traderDrop = 0; address d = RefundLauncher(refundLauncher).newDistributor(platformToken, root); traderDistributor = d; IERC20(platformToken).safeTransfer(d, a); emit TraderDropReleased(d, root, a); } // ================================================================================== views function tokenCount() external view returns (uint256) { return tokens.length; } /// A launch's pool key (HYPE is currency0, address zero, so the token is always currency1). function poolKey(address token) external pure returns (PoolKey memory) { return _key(token); } /// A pool's price and the launch position, for the page: current sqrt price (tokens per HYPE, Q64.96), the /// position's liquidity and its top sqrt price. HYPE in the position is liquidity × (1/sqrtP − 1/sqrtTop). function poolState(address token) external view returns (uint160 sqrtPriceX96, uint128 liquidity, uint160 sqrtTopX96) { Launch storage L = _launch(token); sqrtPriceX96 = _sqrtPrice(L.poolId); liquidity = positions.getPositionLiquidity(L.positionId); sqrtTopX96 = TickMath.getSqrtPriceAtTick(L.topTick); } /// Ownership can't be renounced: $TAX is launched by the owner. function renounceOwnership() public pure override { revert("can't renounce"); } // =============================================================================== internal function _launch(address token) private view returns (Launch storage L) { L = launches[token]; require(L.launchedAt != 0, "unknown token"); } function _checkTick(int24 t) private pure { require(t % TICK_SPACING == 0 && t >= -MAX_START_TICK && t <= MAX_START_TICK, "start tick"); } function _key(address token) private pure returns (PoolKey memory) { return PoolKey(address(0), token, POOL_FEE, TICK_SPACING, address(0)); } /// A pool's sqrt price, read from the pool manager's storage (as v4-core's StateLibrary.getSlot0 does). function _sqrtPrice(bytes32 poolId) private view returns (uint160) { return uint160(uint256(poolManager.extsload(keccak256(abi.encodePacked(poolId, POOLS_SLOT))))); } /// Open the HYPE/token pool at the starting price and put `amount` tokens in it as one position covering every /// price from the start down (Uniswap prices the token as tokens per HYPE, so a rising token is a falling tick): /// it holds only the token until someone buys. Leftover rounding is burned. function _openPool(address token, uint256 amount, int24 tick) private returns (bytes32 poolId, uint256 positionId, int24 top) { PoolKey memory key = _key(token); poolId = keccak256(abi.encode(key)); top = -tick; // `tick` is HYPE per token; the pool's tick is tokens per HYPE uint160 sqrtPrice = TickMath.getSqrtPriceAtTick(top); // A pool set up beforehand can't take tokens cheaply: if its price is at or above the start (the token // cheaper), the position still starts at the start price and the first buy crosses the empty gap for free. // A price below the start (the token dearer) would make the mint take HYPE, so it is refused. uint160 current = _sqrtPrice(poolId); if (current == 0) poolManager.initialize(key, sqrtPrice); else require(current >= sqrtPrice, "pool pre-created"); uint128 liquidity = LiquidityAmounts.getLiquidityForAmount1(TickMath.getSqrtPriceAtTick(-MAX_TICK), sqrtPrice, amount); require(liquidity <= MAX_LIQUIDITY, "start price too high for this supply"); IERC20(token).forceApprove(address(permit2), amount); permit2.approve(token, address(positions), SafeCast.toUint160(amount), uint48(block.timestamp)); positionId = positions.nextTokenId(); uint256 before = IERC20(token).balanceOf(address(this)); bytes[] memory p = new bytes[](2); // Single-sided: the price sits on the position's top edge, so it takes no HYPE (amount0Max 0). p[0] = abi.encode(key, -MAX_TICK, top, uint256(liquidity), uint128(0), SafeCast.toUint128(amount), address(this), bytes("")); p[1] = abi.encode(address(0), token); positions.modifyLiquidities(abi.encode(abi.encodePacked(MINT_POSITION, SETTLE_PAIR), p), block.timestamp); uint256 used = before - IERC20(token).balanceOf(address(this)); permit2.approve(token, address(positions), 0, 0); IERC20(token).forceApprove(address(permit2), 0); if (used < amount) LaunchToken(token).burn(amount - used); } function _send(address to, uint256 amount) private { if (amount > 0) Address.sendValue(payable(to), amount); } }